From e848d70c6f6de2a56bfe611c2bc4c5e8183e561d Mon Sep 17 00:00:00 2001 From: Juan Cruz Fortunatti Date: Thu, 13 Aug 2026 23:17:19 +0200 Subject: [PATCH] chore: prepare focused simfile release --- .githooks/fixture-ownership-census-guard.mjs | 42 + .githooks/install.mjs | 29 + .githooks/post-merge | 15 + .githooks/pre-commit | 2 + .githooks/pre-merge-commit | 2 + .github/workflows/deploy-website.yml | 4 + .github/workflows/publish.yml | 7 +- .github/workflows/test.yml | 1 + .gitignore | 3 + AGENTS.md | 8 +- README.md | 27 +- docs/DESIGN.md | 237 +- docs/SITE_DESIGN.md | 9 +- docs/SYSTEMS_VIEW.md | 33 +- .../office-world/Simfile | 5 +- fixtures/e2e/office-world-v0/Simfile | 5 +- .../office-psyche-golden/manifest.json | 94 + .../office-psyche-golden/observe/report.json | 39 + .../raw/daimon/eleanor-animus/causal.jsonl | 3 + .../raw/daimon/eleanor-shadow/causal.jsonl | 3 + .../raw/daimon/eleanor/causal.jsonl | 12 + .../raw/daimon/mara/causal.jsonl | 3 + .../raw/daimon/sam/causal.jsonl | 3 + .../raw/mneme/northwind-recall/causal.jsonl | 5 + .../raw/mneme/northwind-recall/events.jsonl | 8 + .../raw/moltnet/causal.jsonl | 11 + .../raw/moltnet/transcript.json | 178 ++ .../raw/world/causal.jsonl | 6 + .../spawnfile-report.json | 46 + .../spawnfile/export-index.json | 7 + .../office-psyche-golden/world/telemetry.json | 48 + fixtures/sims/README.md | 17 + .../jungian-daimon-org-grok/org/Spawnfile | 40 - .../sims/jungian-daimon-org-grok/org/TEAM.md | 20 - .../org/teams/luna/Spawnfile | 46 - .../org/teams/luna/TEAM.md | 4 - .../org/teams/luna/agents/animus/AGENTS.md | 22 - .../org/teams/luna/agents/animus/Spawnfile | 20 - .../luna/agents/representative/AGENTS.md | 61 - .../luna/agents/representative/Spawnfile | 20 - .../org/teams/luna/agents/shadow/AGENTS.md | 22 - .../org/teams/luna/agents/shadow/Spawnfile | 20 - .../org/teams/selene/Spawnfile | 46 - .../org/teams/selene/TEAM.md | 4 - .../org/teams/selene/agents/animus/AGENTS.md | 22 - .../org/teams/selene/agents/animus/Spawnfile | 20 - .../selene/agents/representative/AGENTS.md | 61 - .../selene/agents/representative/Spawnfile | 20 - .../org/teams/selene/agents/shadow/AGENTS.md | 22 - .../org/teams/selene/agents/shadow/Spawnfile | 20 - .../office-control-noseed-grok/org/Spawnfile | 55 - .../office-control-noseed-grok/org/TEAM.md | 16 - .../org/agents/eleanor/AGENTS.md | 18 - .../org/agents/eleanor/MEMORY.md | 1 - .../org/agents/eleanor/Spawnfile | 21 - .../org/agents/sam/AGENTS.md | 15 - .../org/agents/sam/Spawnfile | 20 - .../office-control-noseed-grok/world/Simfile | 46 - .../sims/office-placebo-grok/org/Spawnfile | 55 - fixtures/sims/office-placebo-grok/org/TEAM.md | 16 - .../org/agents/eleanor/AGENTS.md | 18 - .../org/agents/eleanor/MEMORY.md | 1 - .../org/agents/eleanor/Spawnfile | 21 - .../org/agents/sam/AGENTS.md | 15 - .../org/agents/sam/Spawnfile | 20 - .../sims/office-placebo-grok/world/Simfile | 46 - .../sims/office-pressure-v0/world/Simfile | 7 +- .../sims/office-secret-v0-grok/org/Spawnfile | 55 - .../sims/office-secret-v0-grok/org/TEAM.md | 16 - .../org/agents/eleanor/AGENTS.md | 18 - .../org/agents/eleanor/MEMORY.md | 1 - .../org/agents/eleanor/Spawnfile | 21 - .../org/agents/sam/AGENTS.md | 15 - .../org/agents/sam/Spawnfile | 20 - .../sims/office-secret-v0-grok/world/Simfile | 46 - fixtures/sims/office-secret-v0/world/Simfile | 7 +- fixtures/sims/office-sim-grok/org/Spawnfile | 55 - fixtures/sims/office-sim-grok/org/TEAM.md | 10 - .../org/agents/eleanor/AGENTS.md | 15 - .../org/agents/eleanor/Spawnfile | 20 - .../office-sim-grok/org/agents/sam/AGENTS.md | 13 - .../office-sim-grok/org/agents/sam/Spawnfile | 20 - fixtures/sims/office-sim-grok/world/Simfile | 7 - .../sims/public-dynamics-contract/Simfile | 19 + .../systems/contract.ts | 155 ++ .../systems/runtime-contract.ts | 126 + package-lock.json | 1205 ++++++++- package.json | 80 +- scripts/run-tests.mjs | 73 + scripts/run-tests.test.mjs | 29 + src/AGENTS.md | 7 +- src/cli/AGENTS.md | 16 + src/cli/compiledOrganizationIdentity.test.ts | 66 + src/cli/compiledOrganizationIdentity.ts | 71 + src/cli/composedRouting.test.ts | 57 + src/cli/composedRunArtifacts.test.ts | 84 + src/cli/composedRunArtifacts.ts | 133 + src/cli/composedRunBootstrap.test.ts | 78 + src/cli/composedRunBootstrap.ts | 399 +++ src/cli/composedRunCommand.ts | 202 ++ src/cli/composedViewerBinding.test.ts | 44 + src/cli/composedViewerBinding.ts | 34 + src/cli/credentialBindingProjection.test.ts | 47 + src/cli/credentialBindingProjection.ts | 60 + src/cli/index.test-helper.ts | 79 + src/cli/index.test.ts | 163 +- src/cli/index.ts | 274 +- src/cli/recover.test.ts | 404 +++ src/cli/recover.ts | 36 + src/cli/recoverAuthority.test-helper.ts | 169 ++ src/cli/runArguments.test.ts | 49 + src/cli/runArguments.ts | 118 + src/cli/runRoute.test.ts | 47 + src/cli/runRoute.ts | 47 + src/compose/AGENTS.md | 59 + src/{sims => compose}/CLAUDE.md | 0 src/compose/activation.test.ts | 257 ++ src/compose/activation.ts | 318 +++ src/compose/cleanup.test.ts | 258 ++ src/compose/cleanup.ts | 315 +++ src/compose/commandReceipt.test.ts | 91 + src/compose/commandReceipt.ts | 124 + src/compose/composed-autonomy.test.ts | 36 + src/compose/contracts.ts | 31 + src/compose/execution.ts | 122 + src/compose/finalize-organization.test.ts | 204 ++ src/compose/finalize-organization.ts | 267 ++ src/compose/finalize-world.test.ts | 169 ++ src/compose/finalize-world.ts | 209 ++ src/compose/index.ts | 26 + src/compose/journal.ts | 297 ++ src/compose/journalSession.test.ts | 82 + src/compose/journalSession.ts | 163 ++ src/compose/json.ts | 99 + src/compose/lifecycle.test-helper.ts | 399 +++ src/compose/liveEvidence.test.ts | 71 + src/compose/liveEvidence.ts | 111 + src/compose/liveViewerProjection.test.ts | 171 ++ src/compose/liveViewerProjection.ts | 391 +++ src/compose/phase-journal.test.ts | 202 ++ src/compose/phase.ts | 44 + src/compose/preflight.test.ts | 91 + src/compose/preflight.ts | 44 + src/compose/projectBinding.ts | 150 + src/compose/receipt.ts | 181 ++ src/compose/recovery.test.ts | 155 ++ src/compose/recovery.ts | 240 ++ src/compose/replay.test.ts | 109 + src/compose/replay.ts | 139 + src/compose/request-receipt.test.ts | 135 + src/compose/request.ts | 53 + src/compose/run.test-helper.ts | 302 +++ src/compose/run.test.ts | 235 ++ src/compose/run.ts | 304 +++ src/compose/runRecord.test.ts | 114 + src/compose/runRecord.ts | 202 ++ src/compose/startup-organization.test.ts | 121 + src/compose/startup-organization.ts | 249 ++ src/compose/startup-world.test.ts | 129 + src/compose/startup-world.ts | 175 ++ src/compose/superviseServices.test.ts | 52 + src/compose/supervision.test.ts | 108 + src/compose/supervision.ts | 161 ++ src/compose/types.ts | 36 + src/compose/viewer.test.ts | 178 ++ src/compose/viewer.ts | 57 + src/compose/viewerBinding.test.ts | 66 + src/compose/viewerBinding.ts | 77 + src/dynamics/AGENTS.md | 159 ++ src/dynamics/CLAUDE.md | 1 + src/dynamics/actionRetention.test.ts | 131 + src/dynamics/actionRetention.ts | 119 + src/dynamics/build.test.ts | 291 ++ src/dynamics/build.ts | 395 +++ src/dynamics/buildDeterminism.test.ts | 398 +++ src/dynamics/buildHermetic.test.ts | 348 +++ src/dynamics/buildHostile.test.ts | 351 +++ src/dynamics/buildIdentity.ts | 92 + src/dynamics/buildInput.ts | 114 + src/dynamics/buildLoad.test-helper.ts | 89 + src/dynamics/buildLoad.test.ts | 316 +++ src/dynamics/buildLoad.ts | 230 ++ src/dynamics/buildLoadFiles.ts | 206 ++ src/dynamics/buildPackagePolicy.ts | 131 + src/dynamics/buildReceipt.test-helper.ts | 298 ++ src/dynamics/buildReceipt.test.ts | 255 ++ src/dynamics/buildReceipt.ts | 192 ++ src/dynamics/buildReceiptCreation.test.ts | 222 ++ src/dynamics/buildReceiptHostile.test.ts | 389 +++ src/dynamics/buildReceiptLock.test-helper.ts | 120 + src/dynamics/buildReceiptLock.test.ts | 323 +++ src/dynamics/buildReceiptLock.ts | 271 ++ .../buildReceiptLockAuthority.test-helper.ts | 321 +++ .../buildReceiptLockAuthority.test.ts | 230 ++ src/dynamics/buildReceiptLockAuthority.ts | 343 +++ src/dynamics/buildReceiptLockFiles.ts | 197 ++ src/dynamics/buildReceiptLockPath.ts | 160 ++ .../buildReceiptProjectLockAuthority.ts | 189 ++ src/dynamics/buildReceiptSelfLinks.ts | 79 + src/dynamics/buildReceiptSourceEvidence.ts | 357 +++ src/dynamics/buildReceiptValidation.ts | 376 +++ src/dynamics/buildRuntimeTypes.test.ts | 144 + src/dynamics/buildSourceSnapshot.test.ts | 53 + src/dynamics/buildSourceSnapshot.ts | 65 + src/dynamics/buildStaticCommonJsPolicy.ts | 314 +++ .../buildStaticCompilerHostPolicy.test.ts | 313 +++ src/dynamics/buildStaticCompilerHostPolicy.ts | 164 ++ src/dynamics/buildStaticGraphPolicy.ts | 74 + src/dynamics/buildStaticPolicy.test.ts | 316 +++ src/dynamics/buildStaticPolicy.ts | 386 +++ src/dynamics/buildStaticResolverPolicy.ts | 323 +++ src/dynamics/buildTestSupport.test-helper.ts | 390 +++ src/dynamics/buildTypecheck.ts | 278 ++ src/dynamics/canonicalJson.test.ts | 45 + src/dynamics/canonicalJson.ts | 112 + src/dynamics/cliSafety.test.ts | 92 + src/dynamics/commitmentOutcomes.test.ts | 129 + src/dynamics/commitmentOutcomes.ts | 127 + src/dynamics/index.ts | 36 + src/dynamics/integration.test.ts | 67 + src/dynamics/limits.ts | 56 + src/dynamics/load.test-helper.ts | 121 + src/dynamics/load.test.ts | 198 ++ src/dynamics/load.ts | 26 + src/dynamics/loadCore.ts | 200 ++ src/dynamics/loadPreflight.test.ts | 150 + src/dynamics/loadRunActionSource.test.ts | 160 ++ src/dynamics/loadRunActionSource.ts | 124 + src/dynamics/modulePath.test.ts | 143 + src/dynamics/modulePath.ts | 89 + src/dynamics/publicContractFixture.test.ts | 450 +++ src/dynamics/publicDeclarationSurface.test.ts | 60 + src/dynamics/publicSurface.test.ts | 91 + src/dynamics/retainedCapacity.ts | 28 + src/dynamics/root.ts | 5 + src/dynamics/runActionSource.test.ts | 149 + src/dynamics/runActionSource.ts | 199 ++ src/dynamics/sameDynamicsSessionSnapshot.ts | 9 + src/dynamics/sequenceWatermark.ts | 49 + src/dynamics/session.test.ts | 421 +++ src/dynamics/session.ts | 396 +++ .../sessionCommitmentOutcomes.test.ts | 79 + src/dynamics/sessionContract.ts | 40 + src/dynamics/sessionHardening.test.ts | 425 +++ src/dynamics/sessionIssuance.ts | 22 + .../sessionObservationHardening.test.ts | 50 + src/dynamics/sessionProviderBoundary.ts | 39 + src/dynamics/sessionSnapshotEquality.test.ts | 69 + src/dynamics/sessionSpatial.test.ts | 139 + src/dynamics/sessionValues.ts | 55 + src/dynamics/snapshotValidation.ts | 254 ++ src/dynamics/spatialValidation.test.ts | 115 + src/dynamics/spatialValidation.ts | 83 + src/dynamics/testSupport.test-helper.ts | 152 ++ src/dynamics/types.ts | 324 +++ src/dynamics/validation.ts | 352 +++ src/index.ts | 6 +- src/ledger/AGENTS.md | 2 + src/ledger/index.ts | 1 + src/ledger/principal.test.ts | 98 + src/ledger/principal.ts | 96 + src/ledger/validation.test.ts | 40 +- src/ledger/validation.ts | 74 +- src/moltnet/AGENTS.md | 2 +- src/moltnet/index.ts | 1 + src/moltnet/machine/AGENTS.md | 14 + src/moltnet/machine/client.test.ts | 174 ++ src/moltnet/machine/client.ts | 195 ++ src/moltnet/machine/index.ts | 3 + src/moltnet/machine/protocol.test.ts | 126 + src/moltnet/machine/protocol.ts | 174 ++ src/moltnet/machine/types.ts | 52 + src/moltnet/root.ts | 2 + src/moltnet/types.test.ts | 75 + src/moltnet/types.ts | 17 + src/moltnet/world-participant.test.ts | 39 +- src/moltnet/world-participant.ts | 4 +- src/observe/AGENTS.md | 24 +- src/observe/causalStreams.ts | 42 +- src/observe/compute.ts | 51 +- src/observe/index.ts | 17 +- src/observe/memoryBanks.ts | 35 +- src/observe/observe.test.ts | 2 +- src/observe/observe.ts | 24 +- src/observe/observeCommand.ts | 80 + src/observe/publicSurface.test.ts | 73 + src/observe/rawFiles.test.ts | 149 + src/observe/rawFiles.ts | 145 + src/observe/report.ts | 37 +- src/observe/seedSpread.ts | 8 +- src/observe/seedSpreadArtifacts.ts | 46 +- src/observe/socialPlane.test.ts | 44 + src/observe/socialPlane.ts | 142 + src/observe/summaryLines.test.ts | 79 + src/observe/summaryLines.ts | 26 + src/observe/worldEvidence.test.ts | 80 + src/observe/worldEvidence.ts | 354 +++ src/observe/worldGrants.test.ts | 247 ++ src/observe/worldGrants.ts | 52 + src/ownership/AGENTS.md | 16 + src/ownership/CLAUDE.md | 1 + .../fixtureDependencyScanner.test-helper.ts | 348 +++ .../fixtureDependencyScanner.test.ts | 83 + src/ownership/publicPackageBuildGate.test.ts | 149 + src/publicPackageBuild.test-helper.ts | 278 ++ src/report/probes.test.ts | 6 +- src/run/AGENTS.md | 66 + src/run/CLAUDE.md | 1 + src/run/action-source-packaged.test.ts | 70 + src/run/action-source.acceptance.test.ts | 325 +++ src/run/action-source.test-helper.ts | 377 +++ src/run/dynamics-run-action-causes.test.ts | 23 + src/run/dynamics-run-action-causes.ts | 66 + src/run/dynamics-run-action-source.test.ts | 259 ++ src/run/dynamics-run-action-source.ts | 149 + src/run/dynamics-run-action-ticks.test.ts | 442 +++ src/run/dynamics-run-action-ticks.ts | 348 +++ src/run/dynamics-run-actions.test.ts | 179 ++ src/run/dynamics-run-actions.ts | 173 ++ src/run/dynamics-run-artifacts.test.ts | 501 ++++ src/run/dynamics-run-artifacts.ts | 382 +++ src/run/dynamics-run-commitment-outcomes.ts | 20 + src/run/dynamics-run-contract-versions.ts | 71 + src/run/dynamics-run-frames.test.ts | 252 ++ src/run/dynamics-run-frames.ts | 78 + src/run/dynamics-run-record.test.ts | 425 +++ src/run/dynamics-run-record.ts | 399 +++ src/run/dynamics-run-replay.ts | 117 + src/run/dynamics-run-world-evidence.ts | 82 + src/run/run-driver.test.ts | 271 ++ src/run/run-driver.ts | 123 + src/run/runAutonomyBoundary.test.ts | 43 + src/run/runDeterminismBoundary.test.ts | 404 +++ src/run/world-action-refusals.test.ts | 315 +++ src/run/world-grant-run.test.ts | 425 +++ src/run/world-grant-run.ts | 187 ++ src/runtime/AGENTS.md | 18 +- src/runtime/autonomyBoundary.test.ts | 167 ++ src/runtime/causal-fixture.test.ts | 3 +- src/runtime/causalRecording.test.ts | 282 ++ src/runtime/causalRecording.ts | 346 +++ src/runtime/causalRecordingContract.ts | 107 + src/runtime/causalRecordingJson.ts | 90 + src/runtime/causalReplay.ts | 148 + src/runtime/clock.test.ts | 33 + src/runtime/clock.ts | 24 +- src/runtime/dynamics-guard.test.ts | 27 + src/runtime/dynamics-guard.ts | 10 + src/runtime/emit-causal-fixture.ts | 3 +- src/runtime/index.ts | 7 + src/runtime/presence.test.ts | 149 + src/runtime/publicSurface.test.ts | 106 + src/runtime/rule-actions.ts | 59 +- src/runtime/run-record.test.ts | 17 +- src/runtime/run-record.ts | 12 +- src/runtime/step-tick.ts | 92 +- src/runtime/trace-compile.ts | 26 + src/runtime/trace-run.ts | 8 +- src/runtime/trace.test.ts | 164 +- src/runtime/trace.ts | 1 + src/runtime/traceCausal.test.ts | 167 ++ src/runtime/types.ts | 18 + src/runtime/viewer-spatial.ts | 246 ++ src/runtime/viewer-trace.test.ts | 106 + src/runtime/viewer-trace.ts | 193 +- src/runtime/world-act.test.ts | 3 +- src/schema/AGENTS.md | 2 + src/schema/binding.test.ts | 9 +- src/schema/binding.ts | 2 +- src/schema/dynamics.test.ts | 203 ++ src/schema/identifier.ts | 18 + src/schema/model.ts | 190 +- src/schema/parse.test.ts | 12 +- src/schema/presence.test.ts | 138 + src/schema/semantic.ts | 34 + src/schema/world.test.ts | 119 + src/schema/worldSidecar.test.ts | 37 + src/sims/AGENTS.md | 136 - src/sims/composedJungianSimDriver.ts | 303 --- src/sims/composedOfficeSimDriver.ts | 264 -- src/sims/exchangeWait.test.ts | 201 -- src/sims/exchangeWait.ts | 110 - src/sims/index.ts | 13 - src/sims/moltnetRoomClient.ts | 99 - src/sims/spawnfileCli.ts | 155 -- src/sims/worldDrivenOfficeSimDriver.ts | 292 -- src/sims/worldTickIngest.ts | 2 +- src/sims/worldTickLoop.ts | 186 -- src/spawnfile/AGENTS.md | 41 + src/spawnfile/CLAUDE.md | 1 + src/spawnfile/bootstrapCli.test.ts | 183 ++ src/spawnfile/bootstrapCli.ts | 218 ++ src/spawnfile/cli.test.ts | 248 ++ src/spawnfile/cli.ts | 305 +++ src/spawnfile/evidenceInventory.test.ts | 58 + src/spawnfile/evidenceInventory.ts | 35 + src/spawnfile/index.ts | 35 + .../preparationReceipt.test-helper.ts | 76 + src/spawnfile/preparationReceipt.test.ts | 39 + src/spawnfile/preparationReceipt.ts | 167 ++ src/spawnfile/process.test.ts | 131 + src/spawnfile/process.ts | 212 ++ src/spawnfile/productionOrganizationPorts.ts | 66 + src/spawnfile/productionPorts.ts | 372 +++ src/spawnfile/productionTarget.ts | 115 + src/spawnfile/productionTerminal.ts | 69 + .../productionViewerProjection.test.ts | 175 ++ src/spawnfile/productionViewerProjection.ts | 193 ++ src/spawnfile/publicSurface.test.ts | 109 + .../receipts.test.ts} | 2 +- .../receipts.ts} | 0 src/spawnfile/targetReceipts.test.ts | 197 ++ src/spawnfile/targetReceipts.ts | 251 ++ src/spawnfile/worldEvidenceArchive.test.ts | 119 + src/spawnfile/worldEvidenceArchive.ts | 223 ++ src/test-support/lockedPort.test-helper.ts | 1 + src/test-support/lockedPort.ts | 62 + src/test-support/worldController.ts | 6 + src/view/AGENTS.md | 95 +- src/view/engineProvenance.test.ts | 135 +- src/view/engineProvenance.ts | 56 +- src/view/events.test.ts | 24 + src/view/events.ts | 105 + src/view/index.test.ts | 10 +- src/view/index.ts | 66 +- src/view/playbackDiagnostics.ts | 72 + .../recordedViewerExtensionsState.test.ts | 14 + src/view/runDetect.ts | 50 +- src/view/runFollowLocator.test.ts | 111 + src/view/runFollowLocator.ts | 41 + src/view/runFrames.test.ts | 389 +++ src/view/runFrames.ts | 343 +++ src/view/runFramesSealed.test.ts | 60 + src/view/runLiveBundle.ts | 19 + src/view/runLiveFollow.test.ts | 140 + src/view/runLiveFollow.ts | 204 ++ src/view/runRawArtifacts.test.ts | 14 + src/view/runRawArtifacts.ts | 85 +- src/view/runRecordDrift.test.ts | 203 ++ src/view/runReplayBundle.test.ts | 141 + src/view/runReplayBundle.ts | 102 + src/view/runSealFollower.test.ts | 54 + src/view/runSealFollower.ts | 110 + src/view/runTimeline.test.ts | 107 +- src/view/runTimelineRecords.ts | 11 +- src/view/runTimelineRefs.ts | 9 +- src/view/runTimelineStorylines.test.ts | 97 + src/view/runTimelineTypes.ts | 9 +- src/view/runViewModel.test.ts | 42 + src/view/runViewModel.ts | 8 +- src/view/runViewModelTypes.ts | 22 +- src/view/runViewerExtensionData.test.ts | 107 + src/view/runViewerExtensionData.ts | 117 + src/view/runViewerExtensions.test.ts | 188 ++ src/view/runViewerExtensions.ts | 238 ++ src/view/runViewerProjection.test.ts | 202 ++ src/view/runViewerProjection.ts | 148 + src/view/runWorldTrace.test.ts | 105 +- src/view/runWorldTrace.ts | 150 +- src/view/runWorldTraceSpatial.ts | 282 ++ src/view/server.test.ts | 184 +- src/view/server.ts | 408 +-- src/view/serverRunLive.test.ts | 346 +++ src/view/viewerAssets.test.ts | 86 + src/view/viewerAssets.ts | 91 + src/view/viewerExtensions.test.ts | 93 + src/view/viewerExtensions.ts | 29 + src/view/viewerState.ts | 20 + src/viewer-extension/AGENTS.md | 25 + src/viewer-extension/CLAUDE.md | 1 + src/viewer-extension/descriptor.ts | 135 + src/viewer-extension/index.ts | 141 + src/viewer-extension/motion.ts | 192 ++ .../projectDeclaration.test.ts | 109 + src/viewer-extension/projectDeclaration.ts | 161 ++ src/world-artifact/AGENTS.md | 31 + src/world-artifact/CLAUDE.md | 1 + src/world-artifact/artifact.test.ts | 199 ++ src/world-artifact/artifact.ts | 257 ++ src/world-artifact/authoring.test.ts | 231 ++ src/world-artifact/authoring.ts | 107 + src/world-artifact/authority.ts | 147 + src/world-artifact/clockObservation.test.ts | 36 + src/world-artifact/clockObservation.ts | 63 + src/world-artifact/composerBuild.ts | 156 ++ src/world-artifact/data.ts | 96 + src/world-artifact/entrypoint.test.ts | 37 + src/world-artifact/entrypoint.ts | 53 + src/world-artifact/index.ts | 107 + src/world-artifact/prepare.ts | 119 + src/world-artifact/preparedBundleCache.ts | 305 +++ src/world-artifact/projectBinding.test.ts | 25 + src/world-artifact/projectBinding.ts | 30 + src/world-artifact/readiness.test.ts | 106 + src/world-artifact/readiness.ts | 165 ++ src/world-artifact/runnableBundle.test.ts | 273 ++ src/world-artifact/runnableBundle.ts | 340 +++ .../runnableBundleSidecarFailure.test.ts | 215 ++ src/world-artifact/secretMount.test.ts | 27 + src/world-artifact/secretMount.ts | 21 + src/world-artifact/sidecarCapabilities.ts | 30 + src/world-artifact/sidecarConfiguration.ts | 119 + src/world-artifact/sidecarEntrypoint.ts | 318 +++ src/world-artifact/sidecarFilesystem.test.ts | 118 + src/world-artifact/sidecarFilesystem.ts | 344 +++ src/world-artifact/sidecarPath.ts | 20 + src/world-artifact/sidecarReadiness.ts | 174 ++ .../worldServiceConstruction.ts | 100 + src/world-server/AGENTS.md | 11 + src/world-server/CLAUDE.md | 1 + src/world-server/claim.test.ts | 83 + src/world-server/differential.test.ts | 79 + src/world-server/handler.test.ts | 214 ++ src/world-server/handler.ts | 239 ++ src/world-server/index.ts | 52 + src/world-server/jsonCodec.test.ts | 51 + src/world-server/jsonCodec.ts | 166 ++ src/world-server/jsonServer.test.ts | 89 + src/world-server/jsonServer.ts | 168 ++ src/world-server/mcp.test.ts | 338 +++ src/world-server/mcp.ts | 72 + src/world-server/mcpLifecycle.test.ts | 297 ++ src/world-server/mcpSchemas.ts | 141 + src/world-server/mcpSessionLifecycle.ts | 173 ++ src/world-server/mcpSessionLimits.test.ts | 198 ++ src/world-server/nodeJsonListener.test.ts | 169 ++ src/world-server/nodeJsonListener.ts | 90 + src/world-server/nodeMcpBoundary.ts | 204 ++ src/world-server/nodeMcpListener.ts | 319 +++ src/world-server/parity.test.ts | 216 ++ src/world-surface/AGENTS.md | 46 + src/world-surface/CLAUDE.md | 1 + src/world-surface/authority.ts | 119 + src/world-surface/definition.test-helper.ts | 131 + src/world-surface/definition.test.ts | 319 +++ src/world-surface/definition.ts | 392 +++ src/world-surface/index.ts | 20 + src/world-surface/invoke-hostile.test.ts | 273 ++ src/world-surface/invoke.test.ts | 338 +++ src/world-surface/invoke.ts | 367 +++ .../observation-authority-prototype.test.ts | 292 ++ src/world-surface/observation.ts | 138 + src/world-surface/own-data.ts | 69 + src/world-surface/recommendation.test.ts | 45 + src/world-surface/recommendation.ts | 23 + src/world-surface/rejection.ts | 85 + src/world-surface/schema-prototype.test.ts | 310 +++ src/world-surface/schema-value.test.ts | 29 + src/world-surface/schema-value.ts | 134 + src/world-surface/schema.test.ts | 398 +++ src/world-surface/schema.ts | 361 +++ src/world-surface/synchrony-hostile.test.ts | 141 + src/world-surface/synchrony.ts | 102 + src/world-surface/types.ts | 228 ++ src/world/AGENTS.md | 85 + src/world/CLAUDE.md | 1 + src/world/act.test.ts | 66 + src/world/act.ts | 243 ++ src/world/actDecisionToken.test.ts | 76 + src/world/actEnvelope.test.ts | 68 + src/world/actEnvelope.ts | 102 + src/world/actIdempotency.test.ts | 396 +++ src/world/actTypes.ts | 32 + src/world/actionJournal.test.ts | 99 + src/world/actionJournal.ts | 321 +++ src/world/actionJournalInspection.ts | 58 + src/world/actionJournalLifecycle.test.ts | 178 ++ src/world/actionJournalSnapshot.test.ts | 213 ++ src/world/actionJournalSnapshot.ts | 270 ++ src/world/actionRefusalJournal.test.ts | 155 ++ src/world/actionRefusalJournal.ts | 171 ++ src/world/actionResult.ts | 83 + src/world/actionResultLedger.test.ts | 121 + src/world/actionResultLedger.ts | 149 + src/world/actionResultLedgerInspection.ts | 41 + src/world/actionResultLedgerRestore.test.ts | 212 ++ src/world/actionResultLedgerSnapshot.ts | 124 + src/world/actionResultProjection.test.ts | 131 + src/world/actionResultProjection.ts | 53 + src/world/actionResults.test.ts | 159 ++ src/world/actionResults.ts | 115 + src/world/addresses.test.ts | 103 + src/world/addresses.ts | 128 + src/world/affordances-hostile.test.ts | 105 + src/world/affordances.test.ts | 259 ++ src/world/affordances.ts | 100 + src/world/boundaryObserver.ts | 32 + src/world/capabilityManifest.test.ts | 259 ++ src/world/capabilityManifest.ts | 237 ++ src/world/checkpoint.test.ts | 356 +++ src/world/checkpoint.ts | 30 + src/world/checkpointDynamicsSnapshot.ts | 78 + src/world/checkpointRelations.ts | 167 ++ src/world/checkpointRestore.test.ts | 359 +++ src/world/checkpointRestore.ts | 150 + src/world/checkpointRuntime.test.ts | 217 ++ src/world/checkpointRuntime.ts | 84 + src/world/checkpointSnapshot.ts | 124 + src/world/clockAuthority.test.ts | 329 +++ src/world/clockAuthority.ts | 77 + src/world/controllerAuthority.test.ts | 189 ++ src/world/controllerAuthority.ts | 209 ++ src/world/decisionClaim.test.ts | 80 + src/world/decisionClaim.ts | 120 + src/world/decisionRegistry.test.ts | 399 +++ src/world/decisionRegistry.ts | 394 +++ src/world/decisionRegistryInput.ts | 21 + .../decisionRegistryReachability.test.ts | 125 + src/world/decisionRegistryReservation.test.ts | 182 ++ src/world/decisionRegistryRestore.test.ts | 327 +++ src/world/decisionRegistrySnapshot.ts | 382 +++ src/world/decisionResultReadAdmission.test.ts | 222 ++ src/world/decisionResultReadAdmission.ts | 179 ++ src/world/grantAttestation.ts | 22 + src/world/grantComposition.test.ts | 138 + src/world/grantComposition.ts | 120 + src/world/grants.test.ts | 159 ++ src/world/grants.ts | 165 ++ src/world/hostileJson.test.ts | 100 + src/world/hostileJson.ts | 102 + src/world/index.ts | 71 + src/world/ledger.test.ts | 77 + src/world/ledger.ts | 208 ++ src/world/observe.test.ts | 220 ++ src/world/observe.ts | 139 + src/world/readLedgerSnapshot.test.ts | 68 + src/world/readLedgerSnapshot.ts | 83 + src/world/requestLedger.test.ts | 259 ++ src/world/requestLedger.ts | 150 + src/world/requestLedgerInspection.ts | 16 + src/world/requestLedgerSnapshot.ts | 185 ++ src/world/runtime.test-helper.ts | 142 + src/world/runtime.test.ts | 186 ++ src/world/runtime.ts | 395 +++ src/world/runtimeActionResults.test.ts | 397 +++ src/world/runtimeComposition.ts | 49 + .../worldActionCallbacksRealSession.test.ts | 264 ++ .../worldActionIngressRealSession.test.ts | 141 + .../worldActionMechanicsRealSession.test.ts | 240 ++ .../worldActionProjectionRealSession.test.ts | 190 ++ src/world/worldActionRealSession.test.ts | 86 + tools/AGENTS.md | 10 + tools/CLAUDE.md | 1 + tools/verify-package-closure.mjs | 225 ++ tsconfig.build.json | 5 +- tsconfig.json | 1 + tsconfig.web.json | 5 +- web/public/models/ATTRIBUTION.md | 14 + web/public/models/man.glb | Bin 0 -> 493196 bytes web/src/chrome/AGENTS.md | 27 +- web/src/chrome/ScrubBar.tsx | 133 +- web/src/chrome/playbackCadence.test.ts | 431 +++ web/src/chrome/playbackCadence.ts | 306 +++ web/src/main.tsx | 34 +- web/src/portals/AGENTS.md | 31 +- web/src/portals/MembraneView.test.ts | 283 ++ web/src/portals/MembraneView.tsx | 37 +- web/src/portals/StorylinePortal.tsx | 10 +- web/src/store/timeline.test.ts | 1 + web/src/store/timeline.ts | 7 +- web/src/styles-base.css | 3 + web/src/styles-layout.css | 123 +- web/src/styles-map.css | 199 ++ web/src/styles-playback.css | 62 + web/src/styles-replay.css | 460 +++- web/src/styles.css | 1 + web/src/viewer/AGENTS.md | 33 +- web/src/viewer/ActionFeedPane.test.ts | 231 ++ web/src/viewer/ActionFeedPane.tsx | 156 ++ web/src/viewer/ActionFeedRows.test.ts | 179 ++ web/src/viewer/ActionFeedRows.tsx | 347 +++ web/src/viewer/App.tsx | 206 +- web/src/viewer/AppRows.tsx | 51 + web/src/viewer/AsciiMap.tsx | 156 +- web/src/viewer/CameraFocus.test.ts | 42 + web/src/viewer/CameraFocus.tsx | 2 +- web/src/viewer/ChatPane.test.ts | 37 + web/src/viewer/ChatPane.tsx | 120 + web/src/viewer/DynamicGlyphScene.test.ts | 36 + web/src/viewer/DynamicGlyphScene.tsx | 288 ++ web/src/viewer/DynamicSceneOverlay.tsx | 148 + web/src/viewer/NodeDetails.tsx | 61 + web/src/viewer/ReplayPanes.test.ts | 152 ++ web/src/viewer/ReplayPanes.tsx | 403 ++- web/src/viewer/RunMetaPanels.tsx | 27 +- web/src/viewer/RunReplayShell.tsx | 258 +- web/src/viewer/SceneCamera.test.ts | 36 + web/src/viewer/SceneCamera.tsx | 218 ++ web/src/viewer/SceneGeometry.tsx | 49 +- web/src/viewer/SceneLabels.tsx | 8 +- web/src/viewer/SceneLayers.tsx | 106 + web/src/viewer/SceneMap.tsx | 311 ++- web/src/viewer/SpatialPlaybackControls.tsx | 296 ++ web/src/viewer/WorldHud.tsx | 103 + web/src/viewer/WorldMapRendererHost.tsx | 61 + web/src/viewer/actionFeed.test.ts | 398 +++ web/src/viewer/actionFeed.ts | 178 ++ web/src/viewer/actionFeedCategories.test.ts | 92 + web/src/viewer/actionFeedCategories.ts | 63 + web/src/viewer/actionFeedCommitments.test.ts | 164 ++ web/src/viewer/actionLog.test.ts | 378 +++ web/src/viewer/actionLog.ts | 362 +++ web/src/viewer/actionNarration.test.ts | 399 +++ web/src/viewer/actionNarration.ts | 151 ++ web/src/viewer/actionNarrators.ts | 64 + web/src/viewer/avatarModel.test.ts | 60 + web/src/viewer/avatarModel.ts | 58 +- web/src/viewer/commitmentSpans.test.ts | 206 ++ web/src/viewer/commitmentSpans.ts | 201 ++ web/src/viewer/dynamicGlyphTelemetry.ts | 74 + web/src/viewer/inspectionModel.test.ts | 35 + web/src/viewer/inspectionModel.ts | 40 + web/src/viewer/membraneColor.ts | 4 +- web/src/viewer/membraneMapNodes.test.ts | 53 - web/src/viewer/membraneMapNodes.ts | 42 - web/src/viewer/playbackDiagnostics.ts | 196 ++ web/src/viewer/runLifecycle.ts | 18 + web/src/viewer/runLiveClient.ts | 57 + web/src/viewer/sceneMotion.test.ts | 55 + web/src/viewer/sceneMotion.ts | 15 +- web/src/viewer/spatialObjectModel.test.ts | 97 + web/src/viewer/spatialObjectModel.ts | 142 + web/src/viewer/spatialPlayback.test.ts | 244 ++ web/src/viewer/spatialPlayback.ts | 269 ++ web/src/viewer/spatialReplayTimeline.test.ts | 61 + web/src/viewer/spatialReplayTimeline.ts | 37 + web/src/viewer/spatialSceneModel.test.ts | 105 + web/src/viewer/spatialSceneModel.ts | 116 + web/src/viewer/tileWorld.test.ts | 32 + web/src/viewer/tileWorld.ts | 14 +- web/src/viewer/types.ts | 82 +- web/src/viewer/variableModel.test.ts | 177 +- web/src/viewer/variableModel.ts | 72 +- web/src/viewer/worldMapRendererCatalog.ts | 105 + web/src/viewer/worldMapRendererFrame.test.ts | 27 + web/src/viewer/worldMapRendererFrame.ts | 24 + web/src/viewer/worldModel.test.ts | 32 +- web/src/viewer/worldModel.ts | 57 +- web/vite.config.ts | 12 + website/package-lock.json | 2408 +++++++++++------ website/package.json | 8 +- website/src/content/docs/guides/memetics.md | 31 +- website/src/content/docs/guides/observe.md | 1 + .../docs/guides/spawnfile-integration.md | 29 +- website/src/content/docs/introduction.md | 6 +- website/src/content/docs/quickstart.md | 17 +- website/src/content/docs/reference/cli.md | 5 +- 747 files changed, 91513 insertions(+), 5911 deletions(-) create mode 100644 .githooks/fixture-ownership-census-guard.mjs create mode 100644 .githooks/install.mjs create mode 100755 .githooks/post-merge create mode 100755 .githooks/pre-commit create mode 100755 .githooks/pre-merge-commit create mode 100644 fixtures/observe/office-psyche-golden/manifest.json create mode 100644 fixtures/observe/office-psyche-golden/observe/report.json create mode 100644 fixtures/observe/office-psyche-golden/raw/daimon/eleanor-animus/causal.jsonl create mode 100644 fixtures/observe/office-psyche-golden/raw/daimon/eleanor-shadow/causal.jsonl create mode 100644 fixtures/observe/office-psyche-golden/raw/daimon/eleanor/causal.jsonl create mode 100644 fixtures/observe/office-psyche-golden/raw/daimon/mara/causal.jsonl create mode 100644 fixtures/observe/office-psyche-golden/raw/daimon/sam/causal.jsonl create mode 100644 fixtures/observe/office-psyche-golden/raw/mneme/northwind-recall/causal.jsonl create mode 100644 fixtures/observe/office-psyche-golden/raw/mneme/northwind-recall/events.jsonl create mode 100644 fixtures/observe/office-psyche-golden/raw/moltnet/causal.jsonl create mode 100644 fixtures/observe/office-psyche-golden/raw/moltnet/transcript.json create mode 100644 fixtures/observe/office-psyche-golden/raw/world/causal.jsonl create mode 100644 fixtures/observe/office-psyche-golden/spawnfile-report.json create mode 100644 fixtures/observe/office-psyche-golden/spawnfile/export-index.json create mode 100644 fixtures/observe/office-psyche-golden/world/telemetry.json create mode 100644 fixtures/sims/README.md delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/Spawnfile delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/TEAM.md delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/luna/Spawnfile delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/luna/TEAM.md delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/animus/AGENTS.md delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/animus/Spawnfile delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/representative/AGENTS.md delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/representative/Spawnfile delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/shadow/AGENTS.md delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/shadow/Spawnfile delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/selene/Spawnfile delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/selene/TEAM.md delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/animus/AGENTS.md delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/animus/Spawnfile delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/representative/AGENTS.md delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/representative/Spawnfile delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/shadow/AGENTS.md delete mode 100644 fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/shadow/Spawnfile delete mode 100644 fixtures/sims/office-control-noseed-grok/org/Spawnfile delete mode 100644 fixtures/sims/office-control-noseed-grok/org/TEAM.md delete mode 100644 fixtures/sims/office-control-noseed-grok/org/agents/eleanor/AGENTS.md delete mode 100644 fixtures/sims/office-control-noseed-grok/org/agents/eleanor/MEMORY.md delete mode 100644 fixtures/sims/office-control-noseed-grok/org/agents/eleanor/Spawnfile delete mode 100644 fixtures/sims/office-control-noseed-grok/org/agents/sam/AGENTS.md delete mode 100644 fixtures/sims/office-control-noseed-grok/org/agents/sam/Spawnfile delete mode 100644 fixtures/sims/office-control-noseed-grok/world/Simfile delete mode 100644 fixtures/sims/office-placebo-grok/org/Spawnfile delete mode 100644 fixtures/sims/office-placebo-grok/org/TEAM.md delete mode 100644 fixtures/sims/office-placebo-grok/org/agents/eleanor/AGENTS.md delete mode 100644 fixtures/sims/office-placebo-grok/org/agents/eleanor/MEMORY.md delete mode 100644 fixtures/sims/office-placebo-grok/org/agents/eleanor/Spawnfile delete mode 100644 fixtures/sims/office-placebo-grok/org/agents/sam/AGENTS.md delete mode 100644 fixtures/sims/office-placebo-grok/org/agents/sam/Spawnfile delete mode 100644 fixtures/sims/office-placebo-grok/world/Simfile delete mode 100644 fixtures/sims/office-secret-v0-grok/org/Spawnfile delete mode 100644 fixtures/sims/office-secret-v0-grok/org/TEAM.md delete mode 100644 fixtures/sims/office-secret-v0-grok/org/agents/eleanor/AGENTS.md delete mode 100644 fixtures/sims/office-secret-v0-grok/org/agents/eleanor/MEMORY.md delete mode 100644 fixtures/sims/office-secret-v0-grok/org/agents/eleanor/Spawnfile delete mode 100644 fixtures/sims/office-secret-v0-grok/org/agents/sam/AGENTS.md delete mode 100644 fixtures/sims/office-secret-v0-grok/org/agents/sam/Spawnfile delete mode 100644 fixtures/sims/office-secret-v0-grok/world/Simfile delete mode 100644 fixtures/sims/office-sim-grok/org/Spawnfile delete mode 100644 fixtures/sims/office-sim-grok/org/TEAM.md delete mode 100644 fixtures/sims/office-sim-grok/org/agents/eleanor/AGENTS.md delete mode 100644 fixtures/sims/office-sim-grok/org/agents/eleanor/Spawnfile delete mode 100644 fixtures/sims/office-sim-grok/org/agents/sam/AGENTS.md delete mode 100644 fixtures/sims/office-sim-grok/org/agents/sam/Spawnfile delete mode 100644 fixtures/sims/office-sim-grok/world/Simfile create mode 100644 fixtures/sims/public-dynamics-contract/Simfile create mode 100644 fixtures/sims/public-dynamics-contract/systems/contract.ts create mode 100644 fixtures/sims/public-dynamics-contract/systems/runtime-contract.ts create mode 100644 scripts/run-tests.mjs create mode 100644 scripts/run-tests.test.mjs create mode 100644 src/cli/compiledOrganizationIdentity.test.ts create mode 100644 src/cli/compiledOrganizationIdentity.ts create mode 100644 src/cli/composedRouting.test.ts create mode 100644 src/cli/composedRunArtifacts.test.ts create mode 100644 src/cli/composedRunArtifacts.ts create mode 100644 src/cli/composedRunBootstrap.test.ts create mode 100644 src/cli/composedRunBootstrap.ts create mode 100644 src/cli/composedRunCommand.ts create mode 100644 src/cli/composedViewerBinding.test.ts create mode 100644 src/cli/composedViewerBinding.ts create mode 100644 src/cli/credentialBindingProjection.test.ts create mode 100644 src/cli/credentialBindingProjection.ts create mode 100644 src/cli/index.test-helper.ts create mode 100644 src/cli/recover.test.ts create mode 100644 src/cli/recover.ts create mode 100644 src/cli/recoverAuthority.test-helper.ts create mode 100644 src/cli/runArguments.test.ts create mode 100644 src/cli/runArguments.ts create mode 100644 src/cli/runRoute.test.ts create mode 100644 src/cli/runRoute.ts create mode 100644 src/compose/AGENTS.md rename src/{sims => compose}/CLAUDE.md (100%) create mode 100644 src/compose/activation.test.ts create mode 100644 src/compose/activation.ts create mode 100644 src/compose/cleanup.test.ts create mode 100644 src/compose/cleanup.ts create mode 100644 src/compose/commandReceipt.test.ts create mode 100644 src/compose/commandReceipt.ts create mode 100644 src/compose/composed-autonomy.test.ts create mode 100644 src/compose/contracts.ts create mode 100644 src/compose/execution.ts create mode 100644 src/compose/finalize-organization.test.ts create mode 100644 src/compose/finalize-organization.ts create mode 100644 src/compose/finalize-world.test.ts create mode 100644 src/compose/finalize-world.ts create mode 100644 src/compose/index.ts create mode 100644 src/compose/journal.ts create mode 100644 src/compose/journalSession.test.ts create mode 100644 src/compose/journalSession.ts create mode 100644 src/compose/json.ts create mode 100644 src/compose/lifecycle.test-helper.ts create mode 100644 src/compose/liveEvidence.test.ts create mode 100644 src/compose/liveEvidence.ts create mode 100644 src/compose/liveViewerProjection.test.ts create mode 100644 src/compose/liveViewerProjection.ts create mode 100644 src/compose/phase-journal.test.ts create mode 100644 src/compose/phase.ts create mode 100644 src/compose/preflight.test.ts create mode 100644 src/compose/preflight.ts create mode 100644 src/compose/projectBinding.ts create mode 100644 src/compose/receipt.ts create mode 100644 src/compose/recovery.test.ts create mode 100644 src/compose/recovery.ts create mode 100644 src/compose/replay.test.ts create mode 100644 src/compose/replay.ts create mode 100644 src/compose/request-receipt.test.ts create mode 100644 src/compose/request.ts create mode 100644 src/compose/run.test-helper.ts create mode 100644 src/compose/run.test.ts create mode 100644 src/compose/run.ts create mode 100644 src/compose/runRecord.test.ts create mode 100644 src/compose/runRecord.ts create mode 100644 src/compose/startup-organization.test.ts create mode 100644 src/compose/startup-organization.ts create mode 100644 src/compose/startup-world.test.ts create mode 100644 src/compose/startup-world.ts create mode 100644 src/compose/superviseServices.test.ts create mode 100644 src/compose/supervision.test.ts create mode 100644 src/compose/supervision.ts create mode 100644 src/compose/types.ts create mode 100644 src/compose/viewer.test.ts create mode 100644 src/compose/viewer.ts create mode 100644 src/compose/viewerBinding.test.ts create mode 100644 src/compose/viewerBinding.ts create mode 100644 src/dynamics/AGENTS.md create mode 120000 src/dynamics/CLAUDE.md create mode 100644 src/dynamics/actionRetention.test.ts create mode 100644 src/dynamics/actionRetention.ts create mode 100644 src/dynamics/build.test.ts create mode 100644 src/dynamics/build.ts create mode 100644 src/dynamics/buildDeterminism.test.ts create mode 100644 src/dynamics/buildHermetic.test.ts create mode 100644 src/dynamics/buildHostile.test.ts create mode 100644 src/dynamics/buildIdentity.ts create mode 100644 src/dynamics/buildInput.ts create mode 100644 src/dynamics/buildLoad.test-helper.ts create mode 100644 src/dynamics/buildLoad.test.ts create mode 100644 src/dynamics/buildLoad.ts create mode 100644 src/dynamics/buildLoadFiles.ts create mode 100644 src/dynamics/buildPackagePolicy.ts create mode 100644 src/dynamics/buildReceipt.test-helper.ts create mode 100644 src/dynamics/buildReceipt.test.ts create mode 100644 src/dynamics/buildReceipt.ts create mode 100644 src/dynamics/buildReceiptCreation.test.ts create mode 100644 src/dynamics/buildReceiptHostile.test.ts create mode 100644 src/dynamics/buildReceiptLock.test-helper.ts create mode 100644 src/dynamics/buildReceiptLock.test.ts create mode 100644 src/dynamics/buildReceiptLock.ts create mode 100644 src/dynamics/buildReceiptLockAuthority.test-helper.ts create mode 100644 src/dynamics/buildReceiptLockAuthority.test.ts create mode 100644 src/dynamics/buildReceiptLockAuthority.ts create mode 100644 src/dynamics/buildReceiptLockFiles.ts create mode 100644 src/dynamics/buildReceiptLockPath.ts create mode 100644 src/dynamics/buildReceiptProjectLockAuthority.ts create mode 100644 src/dynamics/buildReceiptSelfLinks.ts create mode 100644 src/dynamics/buildReceiptSourceEvidence.ts create mode 100644 src/dynamics/buildReceiptValidation.ts create mode 100644 src/dynamics/buildRuntimeTypes.test.ts create mode 100644 src/dynamics/buildSourceSnapshot.test.ts create mode 100644 src/dynamics/buildSourceSnapshot.ts create mode 100644 src/dynamics/buildStaticCommonJsPolicy.ts create mode 100644 src/dynamics/buildStaticCompilerHostPolicy.test.ts create mode 100644 src/dynamics/buildStaticCompilerHostPolicy.ts create mode 100644 src/dynamics/buildStaticGraphPolicy.ts create mode 100644 src/dynamics/buildStaticPolicy.test.ts create mode 100644 src/dynamics/buildStaticPolicy.ts create mode 100644 src/dynamics/buildStaticResolverPolicy.ts create mode 100644 src/dynamics/buildTestSupport.test-helper.ts create mode 100644 src/dynamics/buildTypecheck.ts create mode 100644 src/dynamics/canonicalJson.test.ts create mode 100644 src/dynamics/canonicalJson.ts create mode 100644 src/dynamics/cliSafety.test.ts create mode 100644 src/dynamics/commitmentOutcomes.test.ts create mode 100644 src/dynamics/commitmentOutcomes.ts create mode 100644 src/dynamics/index.ts create mode 100644 src/dynamics/integration.test.ts create mode 100644 src/dynamics/limits.ts create mode 100644 src/dynamics/load.test-helper.ts create mode 100644 src/dynamics/load.test.ts create mode 100644 src/dynamics/load.ts create mode 100644 src/dynamics/loadCore.ts create mode 100644 src/dynamics/loadPreflight.test.ts create mode 100644 src/dynamics/loadRunActionSource.test.ts create mode 100644 src/dynamics/loadRunActionSource.ts create mode 100644 src/dynamics/modulePath.test.ts create mode 100644 src/dynamics/modulePath.ts create mode 100644 src/dynamics/publicContractFixture.test.ts create mode 100644 src/dynamics/publicDeclarationSurface.test.ts create mode 100644 src/dynamics/publicSurface.test.ts create mode 100644 src/dynamics/retainedCapacity.ts create mode 100644 src/dynamics/root.ts create mode 100644 src/dynamics/runActionSource.test.ts create mode 100644 src/dynamics/runActionSource.ts create mode 100644 src/dynamics/sameDynamicsSessionSnapshot.ts create mode 100644 src/dynamics/sequenceWatermark.ts create mode 100644 src/dynamics/session.test.ts create mode 100644 src/dynamics/session.ts create mode 100644 src/dynamics/sessionCommitmentOutcomes.test.ts create mode 100644 src/dynamics/sessionContract.ts create mode 100644 src/dynamics/sessionHardening.test.ts create mode 100644 src/dynamics/sessionIssuance.ts create mode 100644 src/dynamics/sessionObservationHardening.test.ts create mode 100644 src/dynamics/sessionProviderBoundary.ts create mode 100644 src/dynamics/sessionSnapshotEquality.test.ts create mode 100644 src/dynamics/sessionSpatial.test.ts create mode 100644 src/dynamics/sessionValues.ts create mode 100644 src/dynamics/snapshotValidation.ts create mode 100644 src/dynamics/spatialValidation.test.ts create mode 100644 src/dynamics/spatialValidation.ts create mode 100644 src/dynamics/testSupport.test-helper.ts create mode 100644 src/dynamics/types.ts create mode 100644 src/dynamics/validation.ts create mode 100644 src/ledger/principal.test.ts create mode 100644 src/ledger/principal.ts create mode 100644 src/moltnet/machine/AGENTS.md create mode 100644 src/moltnet/machine/client.test.ts create mode 100644 src/moltnet/machine/client.ts create mode 100644 src/moltnet/machine/index.ts create mode 100644 src/moltnet/machine/protocol.test.ts create mode 100644 src/moltnet/machine/protocol.ts create mode 100644 src/moltnet/machine/types.ts create mode 100644 src/moltnet/root.ts create mode 100644 src/moltnet/types.test.ts create mode 100644 src/moltnet/types.ts create mode 100644 src/observe/observeCommand.ts create mode 100644 src/observe/publicSurface.test.ts create mode 100644 src/observe/rawFiles.test.ts create mode 100644 src/observe/rawFiles.ts create mode 100644 src/observe/socialPlane.test.ts create mode 100644 src/observe/socialPlane.ts create mode 100644 src/observe/summaryLines.test.ts create mode 100644 src/observe/summaryLines.ts create mode 100644 src/observe/worldEvidence.test.ts create mode 100644 src/observe/worldEvidence.ts create mode 100644 src/observe/worldGrants.test.ts create mode 100644 src/observe/worldGrants.ts create mode 100644 src/ownership/AGENTS.md create mode 120000 src/ownership/CLAUDE.md create mode 100644 src/ownership/fixtureDependencyScanner.test-helper.ts create mode 100644 src/ownership/fixtureDependencyScanner.test.ts create mode 100644 src/ownership/publicPackageBuildGate.test.ts create mode 100644 src/publicPackageBuild.test-helper.ts create mode 100644 src/run/AGENTS.md create mode 120000 src/run/CLAUDE.md create mode 100644 src/run/action-source-packaged.test.ts create mode 100644 src/run/action-source.acceptance.test.ts create mode 100644 src/run/action-source.test-helper.ts create mode 100644 src/run/dynamics-run-action-causes.test.ts create mode 100644 src/run/dynamics-run-action-causes.ts create mode 100644 src/run/dynamics-run-action-source.test.ts create mode 100644 src/run/dynamics-run-action-source.ts create mode 100644 src/run/dynamics-run-action-ticks.test.ts create mode 100644 src/run/dynamics-run-action-ticks.ts create mode 100644 src/run/dynamics-run-actions.test.ts create mode 100644 src/run/dynamics-run-actions.ts create mode 100644 src/run/dynamics-run-artifacts.test.ts create mode 100644 src/run/dynamics-run-artifacts.ts create mode 100644 src/run/dynamics-run-commitment-outcomes.ts create mode 100644 src/run/dynamics-run-contract-versions.ts create mode 100644 src/run/dynamics-run-frames.test.ts create mode 100644 src/run/dynamics-run-frames.ts create mode 100644 src/run/dynamics-run-record.test.ts create mode 100644 src/run/dynamics-run-record.ts create mode 100644 src/run/dynamics-run-replay.ts create mode 100644 src/run/dynamics-run-world-evidence.ts create mode 100644 src/run/run-driver.test.ts create mode 100644 src/run/run-driver.ts create mode 100644 src/run/runAutonomyBoundary.test.ts create mode 100644 src/run/runDeterminismBoundary.test.ts create mode 100644 src/run/world-action-refusals.test.ts create mode 100644 src/run/world-grant-run.test.ts create mode 100644 src/run/world-grant-run.ts create mode 100644 src/runtime/autonomyBoundary.test.ts create mode 100644 src/runtime/causalRecording.test.ts create mode 100644 src/runtime/causalRecording.ts create mode 100644 src/runtime/causalRecordingContract.ts create mode 100644 src/runtime/causalRecordingJson.ts create mode 100644 src/runtime/causalReplay.ts create mode 100644 src/runtime/clock.test.ts create mode 100644 src/runtime/dynamics-guard.test.ts create mode 100644 src/runtime/dynamics-guard.ts create mode 100644 src/runtime/index.ts create mode 100644 src/runtime/presence.test.ts create mode 100644 src/runtime/publicSurface.test.ts create mode 100644 src/runtime/traceCausal.test.ts create mode 100644 src/runtime/viewer-spatial.ts create mode 100644 src/runtime/viewer-trace.test.ts create mode 100644 src/schema/dynamics.test.ts create mode 100644 src/schema/identifier.ts create mode 100644 src/schema/presence.test.ts create mode 100644 src/schema/world.test.ts create mode 100644 src/schema/worldSidecar.test.ts delete mode 100644 src/sims/AGENTS.md delete mode 100644 src/sims/composedJungianSimDriver.ts delete mode 100644 src/sims/composedOfficeSimDriver.ts delete mode 100644 src/sims/exchangeWait.test.ts delete mode 100644 src/sims/exchangeWait.ts delete mode 100644 src/sims/index.ts delete mode 100644 src/sims/moltnetRoomClient.ts delete mode 100644 src/sims/spawnfileCli.ts delete mode 100644 src/sims/worldDrivenOfficeSimDriver.ts delete mode 100644 src/sims/worldTickLoop.ts create mode 100644 src/spawnfile/AGENTS.md create mode 120000 src/spawnfile/CLAUDE.md create mode 100644 src/spawnfile/bootstrapCli.test.ts create mode 100644 src/spawnfile/bootstrapCli.ts create mode 100644 src/spawnfile/cli.test.ts create mode 100644 src/spawnfile/cli.ts create mode 100644 src/spawnfile/evidenceInventory.test.ts create mode 100644 src/spawnfile/evidenceInventory.ts create mode 100644 src/spawnfile/index.ts create mode 100644 src/spawnfile/preparationReceipt.test-helper.ts create mode 100644 src/spawnfile/preparationReceipt.test.ts create mode 100644 src/spawnfile/preparationReceipt.ts create mode 100644 src/spawnfile/process.test.ts create mode 100644 src/spawnfile/process.ts create mode 100644 src/spawnfile/productionOrganizationPorts.ts create mode 100644 src/spawnfile/productionPorts.ts create mode 100644 src/spawnfile/productionTarget.ts create mode 100644 src/spawnfile/productionTerminal.ts create mode 100644 src/spawnfile/productionViewerProjection.test.ts create mode 100644 src/spawnfile/productionViewerProjection.ts create mode 100644 src/spawnfile/publicSurface.test.ts rename src/{sims/spawnfileReceipts.test.ts => spawnfile/receipts.test.ts} (99%) rename src/{sims/spawnfileReceipts.ts => spawnfile/receipts.ts} (100%) create mode 100644 src/spawnfile/targetReceipts.test.ts create mode 100644 src/spawnfile/targetReceipts.ts create mode 100644 src/spawnfile/worldEvidenceArchive.test.ts create mode 100644 src/spawnfile/worldEvidenceArchive.ts create mode 100644 src/test-support/lockedPort.test-helper.ts create mode 100644 src/test-support/lockedPort.ts create mode 100644 src/test-support/worldController.ts create mode 100644 src/view/events.test.ts create mode 100644 src/view/events.ts create mode 100644 src/view/playbackDiagnostics.ts create mode 100644 src/view/recordedViewerExtensionsState.test.ts create mode 100644 src/view/runFollowLocator.test.ts create mode 100644 src/view/runFollowLocator.ts create mode 100644 src/view/runFrames.test.ts create mode 100644 src/view/runFrames.ts create mode 100644 src/view/runFramesSealed.test.ts create mode 100644 src/view/runLiveBundle.ts create mode 100644 src/view/runLiveFollow.test.ts create mode 100644 src/view/runLiveFollow.ts create mode 100644 src/view/runRecordDrift.test.ts create mode 100644 src/view/runReplayBundle.test.ts create mode 100644 src/view/runReplayBundle.ts create mode 100644 src/view/runSealFollower.test.ts create mode 100644 src/view/runSealFollower.ts create mode 100644 src/view/runTimelineStorylines.test.ts create mode 100644 src/view/runViewerExtensionData.test.ts create mode 100644 src/view/runViewerExtensionData.ts create mode 100644 src/view/runViewerExtensions.test.ts create mode 100644 src/view/runViewerExtensions.ts create mode 100644 src/view/runViewerProjection.test.ts create mode 100644 src/view/runViewerProjection.ts create mode 100644 src/view/runWorldTraceSpatial.ts create mode 100644 src/view/serverRunLive.test.ts create mode 100644 src/view/viewerAssets.test.ts create mode 100644 src/view/viewerAssets.ts create mode 100644 src/view/viewerExtensions.test.ts create mode 100644 src/view/viewerExtensions.ts create mode 100644 src/view/viewerState.ts create mode 100644 src/viewer-extension/AGENTS.md create mode 120000 src/viewer-extension/CLAUDE.md create mode 100644 src/viewer-extension/descriptor.ts create mode 100644 src/viewer-extension/index.ts create mode 100644 src/viewer-extension/motion.ts create mode 100644 src/viewer-extension/projectDeclaration.test.ts create mode 100644 src/viewer-extension/projectDeclaration.ts create mode 100644 src/world-artifact/AGENTS.md create mode 120000 src/world-artifact/CLAUDE.md create mode 100644 src/world-artifact/artifact.test.ts create mode 100644 src/world-artifact/artifact.ts create mode 100644 src/world-artifact/authoring.test.ts create mode 100644 src/world-artifact/authoring.ts create mode 100644 src/world-artifact/authority.ts create mode 100644 src/world-artifact/clockObservation.test.ts create mode 100644 src/world-artifact/clockObservation.ts create mode 100644 src/world-artifact/composerBuild.ts create mode 100644 src/world-artifact/data.ts create mode 100644 src/world-artifact/entrypoint.test.ts create mode 100644 src/world-artifact/entrypoint.ts create mode 100644 src/world-artifact/index.ts create mode 100644 src/world-artifact/prepare.ts create mode 100644 src/world-artifact/preparedBundleCache.ts create mode 100644 src/world-artifact/projectBinding.test.ts create mode 100644 src/world-artifact/projectBinding.ts create mode 100644 src/world-artifact/readiness.test.ts create mode 100644 src/world-artifact/readiness.ts create mode 100644 src/world-artifact/runnableBundle.test.ts create mode 100644 src/world-artifact/runnableBundle.ts create mode 100644 src/world-artifact/runnableBundleSidecarFailure.test.ts create mode 100644 src/world-artifact/secretMount.test.ts create mode 100644 src/world-artifact/secretMount.ts create mode 100644 src/world-artifact/sidecarCapabilities.ts create mode 100644 src/world-artifact/sidecarConfiguration.ts create mode 100644 src/world-artifact/sidecarEntrypoint.ts create mode 100644 src/world-artifact/sidecarFilesystem.test.ts create mode 100644 src/world-artifact/sidecarFilesystem.ts create mode 100644 src/world-artifact/sidecarPath.ts create mode 100644 src/world-artifact/sidecarReadiness.ts create mode 100644 src/world-artifact/worldServiceConstruction.ts create mode 100644 src/world-server/AGENTS.md create mode 120000 src/world-server/CLAUDE.md create mode 100644 src/world-server/claim.test.ts create mode 100644 src/world-server/differential.test.ts create mode 100644 src/world-server/handler.test.ts create mode 100644 src/world-server/handler.ts create mode 100644 src/world-server/index.ts create mode 100644 src/world-server/jsonCodec.test.ts create mode 100644 src/world-server/jsonCodec.ts create mode 100644 src/world-server/jsonServer.test.ts create mode 100644 src/world-server/jsonServer.ts create mode 100644 src/world-server/mcp.test.ts create mode 100644 src/world-server/mcp.ts create mode 100644 src/world-server/mcpLifecycle.test.ts create mode 100644 src/world-server/mcpSchemas.ts create mode 100644 src/world-server/mcpSessionLifecycle.ts create mode 100644 src/world-server/mcpSessionLimits.test.ts create mode 100644 src/world-server/nodeJsonListener.test.ts create mode 100644 src/world-server/nodeJsonListener.ts create mode 100644 src/world-server/nodeMcpBoundary.ts create mode 100644 src/world-server/nodeMcpListener.ts create mode 100644 src/world-server/parity.test.ts create mode 100644 src/world-surface/AGENTS.md create mode 120000 src/world-surface/CLAUDE.md create mode 100644 src/world-surface/authority.ts create mode 100644 src/world-surface/definition.test-helper.ts create mode 100644 src/world-surface/definition.test.ts create mode 100644 src/world-surface/definition.ts create mode 100644 src/world-surface/index.ts create mode 100644 src/world-surface/invoke-hostile.test.ts create mode 100644 src/world-surface/invoke.test.ts create mode 100644 src/world-surface/invoke.ts create mode 100644 src/world-surface/observation-authority-prototype.test.ts create mode 100644 src/world-surface/observation.ts create mode 100644 src/world-surface/own-data.ts create mode 100644 src/world-surface/recommendation.test.ts create mode 100644 src/world-surface/recommendation.ts create mode 100644 src/world-surface/rejection.ts create mode 100644 src/world-surface/schema-prototype.test.ts create mode 100644 src/world-surface/schema-value.test.ts create mode 100644 src/world-surface/schema-value.ts create mode 100644 src/world-surface/schema.test.ts create mode 100644 src/world-surface/schema.ts create mode 100644 src/world-surface/synchrony-hostile.test.ts create mode 100644 src/world-surface/synchrony.ts create mode 100644 src/world-surface/types.ts create mode 100644 src/world/AGENTS.md create mode 120000 src/world/CLAUDE.md create mode 100644 src/world/act.test.ts create mode 100644 src/world/act.ts create mode 100644 src/world/actDecisionToken.test.ts create mode 100644 src/world/actEnvelope.test.ts create mode 100644 src/world/actEnvelope.ts create mode 100644 src/world/actIdempotency.test.ts create mode 100644 src/world/actTypes.ts create mode 100644 src/world/actionJournal.test.ts create mode 100644 src/world/actionJournal.ts create mode 100644 src/world/actionJournalInspection.ts create mode 100644 src/world/actionJournalLifecycle.test.ts create mode 100644 src/world/actionJournalSnapshot.test.ts create mode 100644 src/world/actionJournalSnapshot.ts create mode 100644 src/world/actionRefusalJournal.test.ts create mode 100644 src/world/actionRefusalJournal.ts create mode 100644 src/world/actionResult.ts create mode 100644 src/world/actionResultLedger.test.ts create mode 100644 src/world/actionResultLedger.ts create mode 100644 src/world/actionResultLedgerInspection.ts create mode 100644 src/world/actionResultLedgerRestore.test.ts create mode 100644 src/world/actionResultLedgerSnapshot.ts create mode 100644 src/world/actionResultProjection.test.ts create mode 100644 src/world/actionResultProjection.ts create mode 100644 src/world/actionResults.test.ts create mode 100644 src/world/actionResults.ts create mode 100644 src/world/addresses.test.ts create mode 100644 src/world/addresses.ts create mode 100644 src/world/affordances-hostile.test.ts create mode 100644 src/world/affordances.test.ts create mode 100644 src/world/affordances.ts create mode 100644 src/world/boundaryObserver.ts create mode 100644 src/world/capabilityManifest.test.ts create mode 100644 src/world/capabilityManifest.ts create mode 100644 src/world/checkpoint.test.ts create mode 100644 src/world/checkpoint.ts create mode 100644 src/world/checkpointDynamicsSnapshot.ts create mode 100644 src/world/checkpointRelations.ts create mode 100644 src/world/checkpointRestore.test.ts create mode 100644 src/world/checkpointRestore.ts create mode 100644 src/world/checkpointRuntime.test.ts create mode 100644 src/world/checkpointRuntime.ts create mode 100644 src/world/checkpointSnapshot.ts create mode 100644 src/world/clockAuthority.test.ts create mode 100644 src/world/clockAuthority.ts create mode 100644 src/world/controllerAuthority.test.ts create mode 100644 src/world/controllerAuthority.ts create mode 100644 src/world/decisionClaim.test.ts create mode 100644 src/world/decisionClaim.ts create mode 100644 src/world/decisionRegistry.test.ts create mode 100644 src/world/decisionRegistry.ts create mode 100644 src/world/decisionRegistryInput.ts create mode 100644 src/world/decisionRegistryReachability.test.ts create mode 100644 src/world/decisionRegistryReservation.test.ts create mode 100644 src/world/decisionRegistryRestore.test.ts create mode 100644 src/world/decisionRegistrySnapshot.ts create mode 100644 src/world/decisionResultReadAdmission.test.ts create mode 100644 src/world/decisionResultReadAdmission.ts create mode 100644 src/world/grantAttestation.ts create mode 100644 src/world/grantComposition.test.ts create mode 100644 src/world/grantComposition.ts create mode 100644 src/world/grants.test.ts create mode 100644 src/world/grants.ts create mode 100644 src/world/hostileJson.test.ts create mode 100644 src/world/hostileJson.ts create mode 100644 src/world/index.ts create mode 100644 src/world/ledger.test.ts create mode 100644 src/world/ledger.ts create mode 100644 src/world/observe.test.ts create mode 100644 src/world/observe.ts create mode 100644 src/world/readLedgerSnapshot.test.ts create mode 100644 src/world/readLedgerSnapshot.ts create mode 100644 src/world/requestLedger.test.ts create mode 100644 src/world/requestLedger.ts create mode 100644 src/world/requestLedgerInspection.ts create mode 100644 src/world/requestLedgerSnapshot.ts create mode 100644 src/world/runtime.test-helper.ts create mode 100644 src/world/runtime.test.ts create mode 100644 src/world/runtime.ts create mode 100644 src/world/runtimeActionResults.test.ts create mode 100644 src/world/runtimeComposition.ts create mode 100644 src/world/worldActionCallbacksRealSession.test.ts create mode 100644 src/world/worldActionIngressRealSession.test.ts create mode 100644 src/world/worldActionMechanicsRealSession.test.ts create mode 100644 src/world/worldActionProjectionRealSession.test.ts create mode 100644 src/world/worldActionRealSession.test.ts create mode 100644 tools/AGENTS.md create mode 120000 tools/CLAUDE.md create mode 100644 tools/verify-package-closure.mjs create mode 100644 web/public/models/ATTRIBUTION.md create mode 100644 web/public/models/man.glb create mode 100644 web/src/chrome/playbackCadence.test.ts create mode 100644 web/src/chrome/playbackCadence.ts create mode 100644 web/src/portals/MembraneView.test.ts create mode 100644 web/src/styles-playback.css create mode 100644 web/src/viewer/ActionFeedPane.test.ts create mode 100644 web/src/viewer/ActionFeedPane.tsx create mode 100644 web/src/viewer/ActionFeedRows.test.ts create mode 100644 web/src/viewer/ActionFeedRows.tsx create mode 100644 web/src/viewer/AppRows.tsx create mode 100644 web/src/viewer/CameraFocus.test.ts create mode 100644 web/src/viewer/ChatPane.test.ts create mode 100644 web/src/viewer/ChatPane.tsx create mode 100644 web/src/viewer/DynamicGlyphScene.test.ts create mode 100644 web/src/viewer/DynamicGlyphScene.tsx create mode 100644 web/src/viewer/DynamicSceneOverlay.tsx create mode 100644 web/src/viewer/NodeDetails.tsx create mode 100644 web/src/viewer/ReplayPanes.test.ts create mode 100644 web/src/viewer/SceneCamera.test.ts create mode 100644 web/src/viewer/SceneCamera.tsx create mode 100644 web/src/viewer/SceneLayers.tsx create mode 100644 web/src/viewer/SpatialPlaybackControls.tsx create mode 100644 web/src/viewer/WorldHud.tsx create mode 100644 web/src/viewer/WorldMapRendererHost.tsx create mode 100644 web/src/viewer/actionFeed.test.ts create mode 100644 web/src/viewer/actionFeed.ts create mode 100644 web/src/viewer/actionFeedCategories.test.ts create mode 100644 web/src/viewer/actionFeedCategories.ts create mode 100644 web/src/viewer/actionFeedCommitments.test.ts create mode 100644 web/src/viewer/actionLog.test.ts create mode 100644 web/src/viewer/actionLog.ts create mode 100644 web/src/viewer/actionNarration.test.ts create mode 100644 web/src/viewer/actionNarration.ts create mode 100644 web/src/viewer/actionNarrators.ts create mode 100644 web/src/viewer/avatarModel.test.ts create mode 100644 web/src/viewer/commitmentSpans.test.ts create mode 100644 web/src/viewer/commitmentSpans.ts create mode 100644 web/src/viewer/dynamicGlyphTelemetry.ts create mode 100644 web/src/viewer/inspectionModel.test.ts create mode 100644 web/src/viewer/inspectionModel.ts delete mode 100644 web/src/viewer/membraneMapNodes.test.ts delete mode 100644 web/src/viewer/membraneMapNodes.ts create mode 100644 web/src/viewer/playbackDiagnostics.ts create mode 100644 web/src/viewer/runLifecycle.ts create mode 100644 web/src/viewer/runLiveClient.ts create mode 100644 web/src/viewer/sceneMotion.test.ts create mode 100644 web/src/viewer/spatialObjectModel.test.ts create mode 100644 web/src/viewer/spatialObjectModel.ts create mode 100644 web/src/viewer/spatialPlayback.test.ts create mode 100644 web/src/viewer/spatialPlayback.ts create mode 100644 web/src/viewer/spatialReplayTimeline.test.ts create mode 100644 web/src/viewer/spatialReplayTimeline.ts create mode 100644 web/src/viewer/spatialSceneModel.test.ts create mode 100644 web/src/viewer/spatialSceneModel.ts create mode 100644 web/src/viewer/tileWorld.test.ts create mode 100644 web/src/viewer/worldMapRendererCatalog.ts create mode 100644 web/src/viewer/worldMapRendererFrame.test.ts create mode 100644 web/src/viewer/worldMapRendererFrame.ts diff --git a/.githooks/fixture-ownership-census-guard.mjs b/.githooks/fixture-ownership-census-guard.mjs new file mode 100644 index 0000000..b71b03b --- /dev/null +++ b/.githooks/fixture-ownership-census-guard.mjs @@ -0,0 +1,42 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const pattern = process.env.SIMFILE_CENSUS_HOOK_PATTERN ?? "fixture-ownership census permanently enforces"; +const regenerationCommand = 'SIMFILE_UPDATE_FIXTURE_OWNERSHIP_CENSUS=1 node --import tsx --test --test-name-pattern "explicit maintainer command refreshes the derived ownership census" src/ownership/fixtureOwnershipRatchet.test.ts'; + +if (process.env.SIMFILE_SKIP_CENSUS_HOOK === "1") { + console.error("WARNING: SIMFILE_SKIP_CENSUS_HOOK=1 bypassed the fixture-ownership census guard."); + process.exit(0); +} + +const env = { ...process.env }; +// The child must never be able to repair the artifact this guard is checking. +delete env.SIMFILE_UPDATE_FIXTURE_OWNERSHIP_CENSUS; +// When the guard is itself invoked from inside a node:test run, an inherited +// NODE_TEST_CONTEXT makes the child refuse to run any file ("run() is being called +// recursively") and emit no TAP at all. Without this the guard would report a +// failure it never actually measured. +delete env.NODE_TEST_CONTEXT; +const result = spawnSync(process.execPath, [ + "--import", "tsx", "--test", "--test-reporter=tap", "--test-name-pattern", pattern, + "src/ownership/fixtureOwnershipRatchet.test.ts", +], { cwd: root, env, encoding: "utf8" }); +const stdout = result.stdout ?? ""; +const stderr = result.stderr ?? ""; +const hasExpectedTap = /^# pass 1$/m.test(stdout) + && /^# fail 0$/m.test(stdout) + && /^# tests 1$/m.test(stdout); +const matchedATest = !/^1\.\.0$/m.test(stdout); + +if (result.status !== 0 || !hasExpectedTap || !matchedATest) { + console.error("Fixture-ownership census guard failed or matched no test."); + console.error(`Regenerate with: ${regenerationCommand}`); + console.error("Then git add fixtures/sims/tiny-football/ownership-census.json and retry."); + if (stderr.trim()) console.error(stderr.trim()); + // node --test reports the failing assertion on stdout, so a guard that printed only + // stderr would refuse the commit without ever saying what was uncovered. + if (stdout.trim()) console.error(stdout.trim().split("\n").slice(-60).join("\n")); + process.exit(result.status && result.status > 0 ? result.status : 1); +} diff --git a/.githooks/install.mjs b/.githooks/install.mjs new file mode 100644 index 0000000..89221a4 --- /dev/null +++ b/.githooks/install.mjs @@ -0,0 +1,29 @@ +import { spawnSync } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const probe = spawnSync("git", ["rev-parse", "--show-toplevel"], { + cwd: root, + encoding: "utf8", +}); + +if (probe.status !== 0) { + console.error("Simfile hooks: not a git repository; skipping hook installation."); + process.exit(0); +} + +// `prepare` also runs for git/folder/link installs and for `npm pack`/publish. Only +// configure hooks when this package IS the checkout, never when it is a dependency +// nested inside someone else's repository. +const toplevel = path.resolve(probe.stdout.trim()); +if (toplevel !== path.resolve(root)) { + console.error(`Simfile hooks: ${root} is not the git toplevel (${toplevel}); skipping hook installation.`); + process.exit(0); +} + +const install = spawnSync("git", ["config", "core.hooksPath", ".githooks"], { + cwd: root, + stdio: "inherit", +}); +process.exit(install.status ?? 1); diff --git a/.githooks/post-merge b/.githooks/post-merge new file mode 100755 index 0000000..85bcceb --- /dev/null +++ b/.githooks/post-merge @@ -0,0 +1,15 @@ +#!/bin/sh +# A fast-forward merge creates no commit, so neither pre-commit nor pre-merge-commit +# runs. post-merge always runs, but git ignores its exit code — so this cannot refuse +# the merge, only make a stale census impossible to miss at the moment it lands. +if node "$(dirname "$0")/fixture-ownership-census-guard.mjs"; then + exit 0 +fi +echo "" >&2 +echo "########################################################################" >&2 +echo "# THE MERGE YOU JUST LANDED LEFT THE FIXTURE OWNERSHIP CENSUS STALE. #" >&2 +echo "# post-merge cannot refuse a fast-forward. Regenerate and amend NOW, #" >&2 +echo "# or the next lane reads a failure it did not cause. #" >&2 +echo "########################################################################" >&2 +echo "" >&2 +exit 0 diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..4204190 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,2 @@ +#!/bin/sh +exec node "$(dirname "$0")/fixture-ownership-census-guard.mjs" diff --git a/.githooks/pre-merge-commit b/.githooks/pre-merge-commit new file mode 100755 index 0000000..4204190 --- /dev/null +++ b/.githooks/pre-merge-commit @@ -0,0 +1,2 @@ +#!/bin/sh +exec node "$(dirname "$0")/fixture-ownership-census-guard.mjs" diff --git a/.github/workflows/deploy-website.yml b/.github/workflows/deploy-website.yml index 43df4d5..8c607a4 100644 --- a/.github/workflows/deploy-website.yml +++ b/.github/workflows/deploy-website.yml @@ -33,6 +33,10 @@ jobs: run: npm ci working-directory: website + - name: Audit runtime dependencies + run: npm audit --omit=dev --audit-level=high + working-directory: website + - name: Build run: npm run build working-directory: website diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 193e0dd..be8377d 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -30,6 +30,9 @@ jobs: - name: Install dependencies run: npm ci + - name: Audit runtime dependencies + run: npm audit --omit=dev --audit-level=high + - name: Verify tag matches package version shell: bash run: | @@ -52,8 +55,8 @@ jobs: - name: Build run: npm run build - - name: Verify package contents - run: npm pack --dry-run + - name: Verify isolated package install + run: npm run verify:package-closure - name: Publish to npm run: npm publish diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 9979356..49d5ced 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,5 +15,6 @@ jobs: node-version: 22 cache: npm - run: npm ci + - run: npm audit --omit=dev --audit-level=high - run: npm run typecheck - run: npm test diff --git a/.gitignore b/.gitignore index a9852e1..21a2b6a 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,6 @@ runs/* here.txt .env .DS_Store +fixtures/**/runs/ +fixtures/sims/tiny-football/.local/ +web/public/cssoccer/ diff --git a/AGENTS.md b/AGENTS.md index 9a41251..886e9d0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -14,7 +14,7 @@ of the Simfile v0.1 world mechanics package. ├── package.json # npm package metadata and CLI scripts ├── tsconfig.json # Typecheck config ├── tsconfig.build.json # Build-only emit config -└── src/ # Schema, CLI, and runtime-neutral modules +└── src/ # Schema, dynamics, CLI, and runtime-neutral modules ``` ## Rules @@ -28,3 +28,9 @@ of the Simfile v0.1 world mechanics package. - Keep CLI handlers thin; schema, planning, ledger, and runtime logic belong in modules. - Do not import Spawnfile internals. Consume explicit machine-readable artifacts. - Do not add Docker compilation, runtime auth, or deployment ownership here. +- `simfile run` may compose a linked Spawnfile lifecycle only through documented + CLI operations and versioned receipts. Lifecycle composition never selects, + wakes, invokes, polls, or waits for agent cognition. +- Keep `src/run/` as the timer-free local deterministic writer. Generic composed + lifecycle code belongs in its own implementation folder and must be reused by + any future `simfile dev` watch/debug wrapper. diff --git a/README.md b/README.md index 780428d..f49a7f2 100644 --- a/README.md +++ b/README.md @@ -40,11 +40,30 @@ Node.js 22+. ```bash simfile validate ./Simfile.yaml # check a world +simfile run ./Simfile --view # run a linked world + organization, then watch it +simfile run ./Simfile --local --ticks 200 # bounded mechanics-only diagnostic simfile view runs/ # replay a sealed run — scrub, descend, watch spread simfile view --state .sim # watch a live world simfile observe runs/ # reconcile causal chains + measure spread → report.json ``` +For a linked project, `simfile run` performs lifecycle composition: it starts +the world paused on `simfile.world-sidecar-runtime.v1`, delegates organization +lifecycle to Spawnfile's public CLI, attests the topology and any separately +manifested capability extensions, and atomically activates both owners. Tick 1 +and every later tick have no agent barrier. Organization-declared schedules +wake autonomous runtimes; Simfile never selects, wakes, invokes, polls, or +waits for cognition. Observation recommendations are optional pull-only sense +metadata, never deliveries or wake authority. + +A future `simfile dev` wrapper may add watch/debug ergonomics, but must reuse +this lifecycle rather than own another one. + +The live receipt also binds Spawnfile's pinned +`spawnfile.moltnet-release-identity.v1`: architecture, asset digest, release +version, source revision, and the exact `pi-bridge` capability. Unpinned +`latest` is not a live input. + ## What you can see `simfile view ` serves a local web app that turns a run into an instrument, not a screensaver: @@ -65,7 +84,7 @@ A `Simfile` declares world mechanics, kept deliberately genre-neutral: | **clock** | ticks, phases, sim-time | | **variables** | scoped state with ranges | | **generators** | deterministic or stochastic drivers that move variables | -| **rules** | `when` conditions → effects (wake an agent, post a world message) | +| **rules** | `when` conditions → mechanical effects or observation metadata | | **markers** | scan room traffic for tokens (a seeded secret, a name) | | **probes** | scored assertions evaluated over a run | | **run ledger** | the canonical, causally-ordered record everything else is measured against | @@ -102,8 +121,9 @@ rules: deadline_bites: when: { variable: filing_pressure, above: 0.85 } do: - - action: wake:recommend + - action: moltnet:message to: room:office-floor:case-warroom + content: "Filing pressure crossed the deadline threshold." markers: tenant_name: @@ -113,7 +133,7 @@ markers: probes: deadline_observed: - when: { event: wake.recommended, target: room:office-floor:case-warroom } + when: { event: world.message, target: room:office-floor:case-warroom } expect: { at_least: 1 } ``` @@ -128,7 +148,6 @@ src/schema v0.1 world schema + validator src/runtime deterministic world kernel (clock, generators, rules, markers, probes) src/observe causal reconciliation + spread measurement src/view + web the run-replay viewer (React), served by `simfile view` -src/sims composed-run drivers (shell Spawnfile, seed, observe) docs/ design + research (DESIGN, VIEW_DESIGN, VIEW_STYLEGUIDE, …) ``` diff --git a/docs/DESIGN.md b/docs/DESIGN.md index 03167c9..26494b5 100644 --- a/docs/DESIGN.md +++ b/docs/DESIGN.md @@ -188,7 +188,7 @@ Schema conventions, chosen for readability: There is no sugar — two dialects cost more than the lines they save. The only shorthands are lexical (`range: 0..1`, duration literals), which expand in the lexer; the schema sees only canonical records. Former sugar - keys (`once_at`, `say_in`, `say_to`, `recommend_wake`, `when_above`, + keys (`once_at`, `say_in`, `say_to`, `when_above`, `when_below`) are validation errors that point at the canonical form. - No flow mappings in documented examples — anything with keys gets block form. Short flow sequences of scalars (`uniform: [-0.01, 0.03]`) are @@ -282,13 +282,6 @@ rules: - action: moltnet:dm to: agent:eleanor content: "The landlord's lawyer sends a terse letter." - deadline_bites: - when: - variable: filing_pressure - above: 0.85 - do: - - action: wake:recommend - to: room:office-floor:case-warroom hall_goes_quiet: when: all: @@ -324,12 +317,6 @@ markers: scopes: [room:office-floor:office-hall, room:office-floor:break-room] probes: - deadline_observed: - when: - event: wake.recommended - target: room:office-floor:case-warroom - expect: - at_least: 1 pressure_peaked: when: variable: filing_pressure @@ -360,12 +347,11 @@ Two sections are configuration, not primitives, and add no world semantics: `ledger.store` configure storage and reporting. The seven-primitives rule applies to world mechanics; tracer and storage configuration sit outside it. -`spawnfile:` is a flat source pointer — it names the sibling organization for -humans and for `simfile dev`; a `uses:` wrapper block would be structure for a -future that has not arrived. Planning never parses it: `simfile plan` -consumes the compile report passed via `--spawnfile-plan`, and `simfile dev` -may produce that report by shelling out to `spawnfile compile`, printing the -exact command first. +`spawnfile:` is a flat source pointer naming the sibling organization linked to +the world. A `uses:` wrapper block would be structure for a future that has not +arrived. The resolved pointer routes `simfile run` to lifecycle composition; +planning and composition consume only Spawnfile's public artifacts, CLI +operations, and versioned receipts. Governance is intentionally absent from the first example. It is v3 machinery. The first thing a reader sees should be the kernel, not the proposal workflow. @@ -557,8 +543,9 @@ Generators generate; speech belongs to rules. After each application the variable clamps to its range and rounds to the fixed precision. Variable motion never emits ledger events: variable history is telemetry, refreshed in the observe snapshot only when the stored value -actually changed. The ledger records speech, wakes, marker sightings, -lifecycle, and external/agentic writes — never mechanical variable motion, +actually changed. The ledger records speech, optional local observation +recommendations, marker sightings, lifecycle, and external/agentic writes — +never mechanical variable motion, which is re-derivable by definition. Anything richer should be modeled as a Spawnfile agent. @@ -576,8 +563,8 @@ firing read the same clock and state. The discipline is the `fire:` field: of the block) or `once` (story: fires on the first tick the block holds, then is spent). What used to be a separate "beats" construct is exactly a `fire: once` rule, and dissolving it bought real semantics: every firing now -has an id, is ledgered as `rule.fired`, keys wake coalescing, and is -probe-referenceable. Story sequencing is explicit rather than implied: +has an id, is ledgered as `rule.fired`, and is probe-referenceable. Story +sequencing is explicit rather than implied: a follow-on scene triggers on its predecessor via the `event:` atom (`event: rule.fired` with `actor: ` — `rule.fired` events carry the rule id as their actor). @@ -637,7 +624,6 @@ extended only by spec bump: moltnet:message to: · content world speech into a room moltnet:dm to: · content private perception; validation error if the network has DMs off -wake:recommend to: coalescing semantics unchanged variable:set variable · value clamped; mechanical and re-derivable, so not ledgered variable:delta variable · value clamped; mechanical and @@ -665,13 +651,13 @@ to measure how far the symbol penetrates conversation and memory. ### Events And Ledger The ledger records acts, not motion. The event-kind vocabulary (v1, frozen): -`world.message` · `world.dm` · `wake.recommended` · `rule.fired` · +`world.message` · `world.dm` · `rule.fired` · `marker.seen` · `clock.sync`; the space module adds `presence.changed`, `transit.started`, `transit.arrived`; v3 reserves `entity.*` and `proposal.*`. Naming convention, stated once: actions are imperatives (`ns:verb`), the events they ledger are records (`ns.verbed`) — -`wake:recommend` ledgers `wake.recommended`, `moltnet:message` ledgers -`world.message`, `moltnet:dm` ledgers `world.dm`. `rule.fired` carries the +`moltnet:message` ledgers `world.message`, and `moltnet:dm` ledgers `world.dm`. +`rule.fired` carries the rule id as its `actor`. Entity lifecycle uses game verbs: `entity.spawned`, `entity.despawned`. Mechanical variable motion is telemetry — re-derivable from source + seed + pinned @@ -718,7 +704,7 @@ probe-only vocabulary: - `after:` + `within:` — optional sequence modifiers: the `when:` block is evaluated only after the `after:` block has matched, within the window. This covers the temporal claims fixtures actually make: the leak happened - after the DM; the wake landed within 3 ticks of the crossing; propagation + after the DM; a marker landed within 3 ticks of the crossing; propagation reached room B before room C; pressure fell after the intervention. Reuse pays in expressiveness: invariants ("morale stayed above 0.2 all run" @@ -833,26 +819,27 @@ may need a relation qualifier (in / on / worn / part-of carry different physical meaning). Deferred until a fixture needs more than "inside." This primitive is dormant through v0.1–v2: nothing in the kernel can create an -entity — generators move variables, rules emit variable effects, wakes, and -speech, and `world.act`/`world.propose` are deferred. `entity.spawned` and +entity — generators move variables, rules emit variable effects and speech, +and `world.act`/`world.propose` are deferred. `entity.spawned` and `entity.despawned` are reserved vocabulary. The primitive activates with v3, when the proposal path gives it its first writers. ## World-To-Agent Channels -The world reaches agents through exactly four channels. Three are in-world; -the two push channels are recorded as events. Ambient reads are not recorded — -the ledger explains every world utterance and state change, not what agents -looked at. The fourth channel is out-of-world and reserved. +The world exposes three in-world channels plus one reserved operator channel. +Authored speech and private perceptions are recorded as events. Ambient reads +are not recorded — the ledger explains every world utterance and state change, +not what agents looked at. Observation recommendations belong only to ambient +observation; they are not another delivery channel. ```text ambient pull · world.status / world.observe tools, mounted state - the agent looks; no wake, no speech -public event push · world.message to a room, as a Moltnet participant - shared perception; everyone in the room sees the same -private push · world direct message to one agent -perception pair-scoped memory (the agent's sensorium); DMs must - be enabled on the network for this channel to exist + the agent looks; no speech or cognition trigger +public event · world.message to a room, as a Moltnet participant + shared authored speech; everyone in the room sees it +private · world direct message to one agent +perception pair-scoped authored perception; DMs must be enabled + on the network for this channel to exist operator out-of-world · Daimon control endpoint humans, tests, and operator organizations only; never used by world mechanics @@ -862,16 +849,16 @@ The guardrail that keeps this honest is an influence ladder with an enforceable top rung: ```text -observation raw state exposed for the agent to pull — the snapshot -stimulus a world event the agent perceives — speech, DM perceptions -nudge routing without content mandate — wake:recommend +observation raw state and local metadata exposed for the agent to pull +stimulus an authored world event the agent perceives — speech or a DM command never kernel. Commanding voices exist only as an authored god-agent (rule 6: content, not infrastructure) or through the operator tier, ledgered as external ``` -The kernel machinery tops out at nudge — that boundary is structural, not -philosophical. This is the sharpest contrast with the closest published +The kernel machinery tops out at stimulus, while a recommendation stays on the +observation rung. That boundary is structural, not philosophical. This is the +sharpest contrast with the closest published cousin, DeepMind's Concordia: there a single LLM Game Master adjudicates every agent action by interpreting natural language into world outcomes — world state is LLM-mediated at the resolution step, so it is nondeterministic and @@ -889,11 +876,17 @@ whatever an agent hallucinated it to be. Within stimulus, the authoring norm sta direct ("rain is hammering the windows", not "you should go home") — a norm because authored beat content cannot be machine-checked, and named as such. -World content always travels in-world (rooms or DMs, recorded, policy-gated) -so that transcripts and the ledger explain every world utterance and state -change. Operator actions that touch world state or wake agents are ledgered -with `provenance: external`; only pure control-plane actions (pause, resume, -status reads) go unrecorded. World mechanics never use the operator channel. +Authored world speech always travels in-world (rooms or DMs, recorded and +policy-gated) so transcripts and the ledger explain every world utterance and +state change. A recommendation is instead local, optional, non-blocking, +state-derived metadata in the world event/projection stream. It appears only +through an ordinary granted sense after an agent independently wakes and +chooses to observe; the agent may ignore it. Recommendation publication is +never a Moltnet message, mention, principal-addressed delivery, wake, nudge, or +source of decision authority, and it cannot affect world timing. Operator +actions that touch world state or wake agents are ledgered with +`provenance: external`; only pure control-plane actions (pause, resume, status +reads) go unrecorded. World mechanics never use the operator channel. ## Command Set @@ -935,7 +928,8 @@ missing run seed. ### v2: World Runtime ```bash -simfile run [./Simfile] --state .sim/ [--seed X] [--until day:3|ticks:500] +simfile run ./Simfile --view +simfile run ./Simfile --local --ticks 500 [--seed X] simfile status --state .sim/ simfile clock pause|resume|step [n] --state .sim/ simfile ledger --state .sim/ [--follow] [--since 1h] [--scope team:office] @@ -945,18 +939,40 @@ simfile report --state .sim/ --out runs// [--collect] simfile runs list | diff | archive ``` -The runtime owns the mechanical world state and event ledger. Runtime behavior -rules: - -- Wake coalescing: at most one pending `wake.recommended` per - `(target, reason)`; a newer recommendation supersedes, never queues. The - clock does not wait for agents. `reason` is the id of the emitting rule. -- Wake delivery: a `wake:recommend` action is delivered as world speech — a - directed Moltnet message to the target room (mentioning the room's lead - when it has one) — riding the existing bridge wake machinery. - `wake.recommended` is the ledger record of that speech act. The world owns - no wake path because speech is its wake path; no new transport exists, and - the operator endpoint stays untouched. +When the resolved Simfile links a Spawnfile, `simfile run` is the product +command for the complete simulation lifecycle. Its lifecycle-composition layer +starts the world paused and pristine on the base +`simfile.world-sidecar-runtime.v1` ABI, delegates organization lifecycle +operations to the documented Spawnfile CLI, verifies both sides, and atomically +activates the topology. Separately manifested capabilities extend the base ABI +without changing it; `simfile.world-decision-claim.v1` is optional for a world +sidecar but required and attested for the live decision-claim path. A +first-tick receipt proves tick 1 follows activation without a participant +action. Organization-owned schedules then wake autonomous runtimes while the +world ticks independently. This composition never selects an agent, invokes +cognition, waits for an answer, or makes an agent action a clock barrier. + +The organization receipt must carry a pinned +`spawnfile.moltnet-release-identity.v1` whose architecture, asset digest, +release version, source revision, and sole `pi-bridge` capability match the +checked-in authority. A stamp is corroborating evidence, not authority; +unpinned `latest` is rejected. + +`--local --ticks N` is the explicit bounded deterministic diagnostic. During +migration an unlinked Simfile may keep the existing local `--ticks` behavior, +but it cannot produce live-agent evidence. The `src/run/` implementation remains +the timer-free local deterministic writer; the generic lifecycle composer is a +separate layer. + +The world runtime owns the mechanical world state and event ledger. Runtime +behavior rules: + +- Observation recommendations are bounded, local, optional, non-blocking, + state-derived metadata in the world event/projection stream. They are exposed + through an ordinary granted sense and never use Moltnet, a message, mention, + wake, nudge, principal-addressed delivery, or decision authority. An agent + can encounter one only after waking independently and choosing to observe; + it may ignore the metadata. The clock does not wait for agents. - Event fuse: a per-tick maximum event count aborts a runaway tick loudly instead of flooding the ledger. - Single writer: the world runtime is the only ledger writer on every backend. @@ -970,7 +986,8 @@ v2 delivery is limited to: snapshot (no IPC). HTTP and MCP are later delivery forms, not v2 scope. Nothing in v2 requires a -new transport: wakes travel as Moltnet speech, observation travels as files. +new transport: authored speech uses its declared channel, while observation +and recommendation metadata remain pull-only files or sense data. ### v3: Governance And Evolution @@ -989,16 +1006,16 @@ These commands turn agent-originated world changes into reviewable artifacts. Simfile can create ephemeral runtime state immediately, but durable topology changes should become patches or pull requests. -`simfile dev` ships with v2 — it wraps the world runtime and the Spawnfile org -together, so it has no earlier home. It should print the exact `spawnfile` -command it would run before it runs anything. Legibility wins over magic. +`simfile dev` is an optional future developer wrapper for rebuild, watch, and +debug ergonomics. It must reuse the same composed lifecycle as `simfile run`; +it cannot own a second startup, supervision, or teardown implementation. ## Storage And Scale The ledger and telemetry are different things and are stored differently. - Ledger: semantically meaningful, replay-relevant events — world messages, - rule firings, wake recommendations, marker sightings, entity lifecycle, + rule firings, local observation recommendations, marker sightings, entity lifecycle, and — once acts land — agentic `world.act`. Low rate. - Telemetry: high-frequency variable series. Not ledgered. Variable state is re-derivable from source + seed plus the replayed agentic/external event @@ -1083,8 +1100,8 @@ normative contract: previous-tick values), then measured variables refresh their windows, then derived variables recompute in topological order, then rules evaluate in lexicographic id order. Rule `variable:*` actions and agentic/external - events land at the next tick boundary, in ledger order; rule speech and - wakes flush at end of tick. + events land at the next tick boundary, in ledger order; rule speech flushes + at end of tick. - Stochastic streams: each draw is `SHA-256(run_seed + ":" + generator_id + ":" + tick + ":" + draw_index)`, mapped to the declared distribution. No generator shares stream state with @@ -1111,20 +1128,31 @@ normative contract: part of replay identity. Time: `sim_time = tick_index × sim_per_tick`; wall cadence (`tick:`) never affects sim semantics. -## Orchestrating Spawnfile +## Composing The Spawnfile Lifecycle + +Simfile may compose a linked Spawnfile organization through documented public +CLI operations and versioned, secret-free receipts. This is lifecycle +composition, not agent orchestration. -Simfile should orchestrate Spawnfile indirectly. +The sequence is world-first: prepare and start the world service, verify its +paused pristine readiness over `simfile.world-sidecar-runtime.v1`, then start +and verify the organization, attest topology and any optional capability +manifests, publish one activation, and observe tick 1. Member schedules and +wake policies remain organization data executed by their runtimes. The world +and Simfile never become a scheduler for cognition. Allowed: - consume a Spawnfile resolved graph artifact to validate ids and topology; - generate tools or resources that Spawnfile can mount; - post world events through Moltnet as a participant; -- emit wake recommendations as directed Moltnet messages, ledgered as - `wake.recommended`; +- emit optional observation-recommendation metadata through ordinary world + events/projections and granted senses, never through wake-eligible transport; - create patches that modify Spawnfile/Simfile source files; -- run a local development loop that shells out to `spawnfile up` only when the - user explicitly chooses that mode. +- delegate organization prepare/start/export/stop operations to Spawnfile's + documented CLI while retaining no target, auth, or deployment authority. +- consume the pinned Pi-bridge Moltnet release identity carried by Spawnfile's + receipts without selecting, downloading, or trusting a release itself. Not allowed: @@ -1139,24 +1167,21 @@ registered like any agent, subject to room write policy, declared and mounted through Spawnfile like any other member's. The world holds credentials, not privileges. -A practical local flow: +A linked project's product flow: ```bash -spawnfile compile ./Spawnfile --out ./plan -spawnfile up ./Spawnfile --detach -simfile run ./Simfile --spawnfile-plan ./plan/spawnfile-report.json --state .sim/ -simfile ledger --state .sim/ --follow +simfile run ./Simfile --view ``` -A higher-level dev flow can later wrap both commands: +For a bounded mechanics-only diagnostic: ```bash -simfile dev ./Simfile --spawnfile ./Spawnfile +simfile run ./Simfile --local --ticks 200 ``` -`simfile dev` may start the world runtime and the Spawnfile org together, but it -should report the exact Spawnfile command it runs and where deployment records -live. +An optional future `simfile dev ./Simfile` wrapper may add rebuild/watch/debug +ergonomics, but it must call the same lifecycle implementation and report the +underlying public operations and deployment-record locations. ## Operator Organizations @@ -1480,7 +1505,8 @@ matters; v3-tagged items are deferred along with governance: - variable motion is telemetry, never ledger events; post-run probe evaluation re-derives the mechanical series and matches streaming verdicts exactly (v2); -- wake coalescing supersedes stale recommendations instead of queueing them; +- local observation recommendation metadata is bounded and may supersede stale + metadata; it never queues a delivery; - the event fuse aborts a runaway tick loudly; - marker tracer passes containment mode and propagation mode tests by content scan, ignoring annotations; @@ -1539,12 +1565,12 @@ Boundaries, named so nobody discovers them by surprise: is different and is designed: discrete entities with single-location custody conserve by construction — see the Objects sketch in the Space Module section. -- No ensembles over agent behavior in the always-on mode. Replay is audit, +- No ensembles over live-agent behavior in the always-on mode. Replay is audit, not re-experiment: agentic events are pinned inputs, and wall-time concurrency reorders turns regardless of model settings. Controlled - ensembles become real in lockstep mode with pinned local engines and - seeded sampling — see Lockstep Mode. API-backed asynchronous societies - stay audit-only. + local scripted ensembles are available only in the deferred diagnostic + lockstep mode with pinned engines and seeded sampling. They are not + live-agent evidence. API-backed asynchronous societies stay audit-only. - Sim time is chained to wall time through LLM inference. Compression trades directly against agent agency: a month-in-a-day world is one its inhabitants act in only a few times per sim-day. This is stack physics, not @@ -1763,15 +1789,15 @@ Minds stay clean: Daimon and OpenClaw do memory, reasoning, and agency; the world does situation. No agent prompt ever contains cab dynamics — only "you arrive at the office; it is 9:12." -## Lockstep Mode (Designed, Deferred) +## Local Scripted Lockstep Diagnostic (Designed, Deferred) -The always-on architecture makes ensembles over agent behavior impossible: -wall-time concurrency reorders turns, prompts diverge, cascades follow. But -the deterministic e2e harness already runs the other way — one-shot cycles in -a fixed order — and lockstep mode absorbs that execution model for real -engines: +The always-on architecture makes controlled ensembles over live-agent behavior +impossible: wall-time concurrency reorders turns, prompts diverge, cascades +follow. The deterministic e2e harness already runs the other way — one-shot +scripted cycles in a fixed order — and a local-only lockstep diagnostic can +absorb that execution model for pinned engines: -- The clock waits: a tick does not advance until its scheduled turns +- The diagnostic clock waits: a tick does not advance until its scripted turns complete, executed in deterministic order. - Sampling is seeded: each turn's sampling seed is `hash(run_seed:agent_id:turn_index)`, passed to the engine (ollama's `seed` @@ -1781,10 +1807,12 @@ engines: certification, not an assumption (GPU float-reduction order can flip logit ties). API engines are excluded by nature. -Under those conditions, N seeds are N controlled samples of agent behavior — -ensembles become experiments, not anecdotes. Lockstep is a run mode -(`simfile run --lockstep`), not a schema change; worlds author identically in -both modes. +Under those conditions, N seeds are N controlled samples of scripted or pinned +local engine behavior. If implemented, `--lockstep` is accepted only with +explicit local diagnostic mode (for example, +`simfile run ./Simfile --local --lockstep --ticks 200`). It is ineligible for +live-agent evidence and never changes the composed runtime rule that the world +clock cannot wait for agent cognition. ## Prior Art @@ -1826,7 +1854,8 @@ instrument (which is why markers trace content through the ledger). - `simfile plan` consumes the existing Spawnfile compile report first. A machine-only plan file is introduced only if the report proves too unstable as a contract. -- `simfile dev` prints the exact Spawnfile command before running anything. +- Any future `simfile dev` wrapper reuses the `simfile run` composed lifecycle + and prints the delegated public operations before running anything. - Durable proposals create real git branches, not loose diff files. - Math lives in the closed `eq` grammar (v1, frozen function list); rule conditions and effects remain data records. There is no general-purpose diff --git a/docs/SITE_DESIGN.md b/docs/SITE_DESIGN.md index cc7652e..e76e7d9 100644 --- a/docs/SITE_DESIGN.md +++ b/docs/SITE_DESIGN.md @@ -81,7 +81,7 @@ screenshot. focused room's outline (the "you are here" semantics from VIEW_DESIGN rule 3 and the viewer bridge rule in ECOSYSTEM-DESIGN section 6). - Right pane: a focus portal transcript with timestamped lines, one - `wake.recommended` line in violet, one `probe pressure_peaked PASS` line + `world.message` line in violet, one `probe pressure_peaked PASS` line using Starlight's success semantics (green), never the brand accent. Why a still and not animation: the hero must load instantly, must be honest @@ -127,8 +127,9 @@ Section 3 content, three step cards with mini code panes: 1. **The world speaks**: a rule fires, `@world` posts to a Moltnet room. The world is a participant, not a puppeteer. -2. **Agents answer freely**: runtimes wake and reply through their normal - bridges. Nothing scripts a thought. +2. **Agents answer freely**: their organization-owned schedules and wake + policies run through normal runtime bridges. Simfile never schedules or + scripts a thought. 3. **The ledger judges**: every act lands in a seeded, replayable ledger; probes turn claims into verdicts with evidence. @@ -522,7 +523,7 @@ Purpose: the world-model data contract: what gets recorded, how, and where. Sections: `Ledger vs Telemetry` (acts vs motion; nothing stored that can be recomputed) · `Event Shape` (`event_id`, `kind`, `sim_time`, `provenance`, `actor`, `target`, `scope`, `payload`) · `Event Kinds` (the frozen v1 table: -`world.message`, `world.dm`, `wake.recommended`, `rule.fired`, `marker.seen`, +`world.message`, `world.dm`, `rule.fired`, `marker.seen`, `clock.sync`; reserved kinds listed as reserved) · `Scope Grammar` (Mneme form; the provider-prefix seam noted honestly) · `Canonical Export` (sorted-key JSONL, stripped non-identity fields, byte-identity asserted here) diff --git a/docs/SYSTEMS_VIEW.md b/docs/SYSTEMS_VIEW.md index 3646eaa..24ad10a 100644 --- a/docs/SYSTEMS_VIEW.md +++ b/docs/SYSTEMS_VIEW.md @@ -169,8 +169,9 @@ rules: variable: filing_pressure above: 0.85 do: - - action: wake:recommend # ...nudge the warroom awake + - action: moltnet:message # explicit world-authored notice to: room:office-floor:case-warroom + content: "Filing pressure crossed the deadline threshold." full_moon_rises: # same anatomy, composed condition when: all: @@ -244,7 +245,7 @@ contained / reached. Leak scanner and meme tracker are the same machine. probes: deadline_observed: # the same when: block, asked as a question when: - event: wake.recommended + event: world.message target: room:office-floor:case-warroom expect: at_least: 1 @@ -349,14 +350,10 @@ rule deadline_bites trips the threshold → ledger - wake.recommended — coalesced, one per room per reason -→ -moltnet - the nudge is world speech — a directed room message; - the bridge wakes the warroom with team context + world.message — one explicit world-authored room notice → agents - Eleanor's team scrambles — their own words, their own plan. + independently scheduled agents may observe it and decide what to do. Nothing scripted their thoughts. ``` @@ -460,7 +457,7 @@ Threshold in, closed effect set out; fires once per crossing, not per tick. |---|---|---| | `when` | required | shared condition block, borrowed syntax: three atoms — `{variable, above, below, for}` (HA `numeric_state` verbatim; `above+below` = band; `for:` = hold continuously, false resets), `phase:`, and `event:` (true on the tick a matching ledger event occurs — rules and one-shot story rules are event-reactive; later `at_place:`); composition is JSON Schema's `all`/`any`/`not`. Takes exactly one node — an atom map or one `all`/`any`/`not` map; bare lists are validation errors. Fires on the false→true transition of the whole composite. Logic is structure, math is `eq` | | `fire` | optional | `per_crossing` (default — standing law) or `once` (story: spent after first firing). The only difference between policy and narrative | -| `do` | canonical | list of namespaced `action:` records from the closed registry: `moltnet:message`, `moltnet:dm` (validation error if DMs off), `wake:recommend` (coalesced per target + rule id), `variable:set`, `variable:delta`. Extended only by spec bump (`entity:spawn` at v3) | +| `do` | canonical | list of namespaced `action:` records from the closed registry: `moltnet:message`, `moltnet:dm` (validation error if DMs off), `variable:set`, `variable:delta`. Extended only by spec bump (`entity:spawn` at v3) | | one form only | no sugar | former compact keys (`say_in`, `once_at`, `when_above`, ...) are validation errors that point at the canonical form — one language, learned once; only lexical shorthands (`range`, durations) survive, expanded in the lexer | | content placeholders | optional | `{variable}` substitutes the current value at fixed precision — pure id lookup, no expressions; world speech can report state without interpreting it | | anatomy | one construct | rules are the only reactive construct — beats dissolved into `fire: once`; every firing has an id, a `rule.fired` record, and probe visibility; sequencing is explicit via `event: rule.fired + actor:` | @@ -473,7 +470,7 @@ Acts, not motion — variable history is telemetry, never events. |---|---|---| | `store.kind` | optional | `jsonl` (default — canonical interchange), `sqlite` (the honest default for real runs), `postgres` (spectators, warehouses) | | `store.path` / `url_env` | optional | path for `jsonl`/`sqlite`; `url_env` for postgres — never a credential in the file | -| event kinds | kernel-defined | `world.message`, `world.dm`, `wake.recommended`, `rule.fired` (rule id as actor), `marker.seen`, `clock.sync`, `presence.*` (space), `entity.*`/`proposal.*` (v3). Naming: actions are imperatives (`ns:verb`), events are records (`ns.verbed`). Variable motion is never an event | +| event kinds | kernel-defined | `world.message`, `world.dm`, `rule.fired` (rule id as actor), `marker.seen`, `clock.sync`, `presence.*` (space), `entity.*`/`proposal.*` (v3). Naming: actions are imperatives (`ns:verb`), events are records (`ns.verbed`). Variable motion is never an event | | envelope | fixed | `event_id (run_id:seq)`, `kind`, `sim_time`, `provenance mechanical|agentic|external`, `actor`, `target`, `scope`, `payload` — per-run constants (`run_id`, `seed`, `schema_version`) live in the manifest; `observed_at` is non-identity, stripped from canonical export | ### `telemetry` · `markers` @@ -525,7 +522,7 @@ Simfile source pointer ───────────────▶ Spawnfil clock ───────── clock.sync ───────────▶ Run record · report variables ───── telemetry snapshots ─▶ Storage backends generators ──── world.message ───────▶ Moltnet -rules ───────── wakes / speech ──────▶ Daimon bridge / Moltnet +rules ───────── speech / metadata ───▶ Moltnet / granted observations ledger + store ─ canonical export ──▶ Run record · report markers ─────── content scan ───────▶ Moltnet + Mneme export probes ───────── report / CI gate ───▶ Run record · report @@ -540,7 +537,7 @@ Moltnet rooms · DMs · world traffic Daimon - runtime · wake delivery + runtime · organization-owned schedules and wake policy Mneme scoped memory @@ -729,11 +726,13 @@ ledger: ### Four Channels, One Influence Ladder -The kernel tops out at nudge: observation (raw state, pulled), stimulus -(perceived world events), nudge (routing, no content mandate), command — which -is never kernel. Commanding voices exist only as an authored agent or through -the ledgered operator tier. Within stimulus, the authoring norm is: describe, -do not direct. +The kernel tops out at stimulus: observation is pulled, while authored speech +is a perceived world event. It never selects a runtime or schedules cognition; +member schedules and wake policy belong to the organization. Observation +recommendations remain optional pull-only metadata, and command is never +kernel. Commanding voices exist only as an authored agent or through the +ledgered operator tier. Within stimulus, the authoring norm is: describe, do +not direct. | Channel | Form | Meaning | Recording | |---|---|---|---| diff --git a/fixtures/e2e/autonomous-office-sim/office-world/Simfile b/fixtures/e2e/autonomous-office-sim/office-world/Simfile index 9dd137e..38fdff4 100644 --- a/fixtures/e2e/autonomous-office-sim/office-world/Simfile +++ b/fixtures/e2e/autonomous-office-sim/office-world/Simfile @@ -75,8 +75,9 @@ rules: variable: filing_pressure above: 0.85 do: - - action: wake:recommend + - action: moltnet:message to: room:office-floor:case-warroom + content: "Observation notice." full_moon_rises: when: all: @@ -107,7 +108,7 @@ markers: probes: deadline_observed: when: - event: wake.recommended + event: world.message target: room:office-floor:case-warroom expect: at_least: 1 diff --git a/fixtures/e2e/office-world-v0/Simfile b/fixtures/e2e/office-world-v0/Simfile index 2e4e526..6c46501 100644 --- a/fixtures/e2e/office-world-v0/Simfile +++ b/fixtures/e2e/office-world-v0/Simfile @@ -78,8 +78,9 @@ rules: variable: filing_pressure above: 0.85 do: - - action: wake:recommend + - action: moltnet:message to: room:office-floor:case-warroom + content: "Observation notice." full_moon_rises: when: all: @@ -110,7 +111,7 @@ markers: probes: deadline_observed: when: - event: wake.recommended + event: world.message target: room:office-floor:case-warroom expect: at_least: 1 diff --git a/fixtures/observe/office-psyche-golden/manifest.json b/fixtures/observe/office-psyche-golden/manifest.json new file mode 100644 index 0000000..551477b --- /dev/null +++ b/fixtures/observe/office-psyche-golden/manifest.json @@ -0,0 +1,94 @@ +{ + "version": "simfile.run-manifest.v1", + "run_id": "run-office-psyche-golden", + "created_at": "2026-07-14T08:00:00.000Z", + "contract_versions": { + "causal-event.v1": "noopolis.causal-event.v1", + "simfile.observe.v1": "simfile.observe.v1", + "simfile.run-manifest.v1": "simfile.run-manifest.v1" + }, + "spawnfile": { "fingerprint": "sf1:officepsychegolden" }, + "artifacts": [ + { + "path": "raw/moltnet/causal.jsonl", + "sha256": "505ad4ca8b68f3607b5343b001dcdbf74852732883e0f650859bdd61e91d922e" + }, + { + "path": "raw/moltnet/transcript.json", + "sha256": "ddd1bf00a8fb0e815942069930d4a4056948f8e9043eefbb65d6acc2189cfb99" + }, + { + "path": "raw/daimon/eleanor/causal.jsonl", + "sha256": "f9badb900544667039e7911312f6cf2dab6c3d1f05d3b02f41551605a1141d4c" + }, + { + "path": "raw/daimon/sam/causal.jsonl", + "sha256": "ef82bb2069456fcc8217ef2f255793e218b047e71979da9338bf8489173b7448" + }, + { + "path": "raw/daimon/eleanor-animus/causal.jsonl", + "sha256": "dbf551ae9c9cc95c20d7efc257f68708bcacc08fb160fba335c618cc09d5187e" + }, + { + "path": "raw/daimon/eleanor-shadow/causal.jsonl", + "sha256": "940a0c0dc06219583f607ba99209a27dbb0498e5126bebd35f347658cc53171d" + }, + { + "path": "raw/daimon/mara/causal.jsonl", + "sha256": "19a2b52798db74f6b36241960c88061e092ece3a83b0dbbd472c42a3b5d5c2ab" + }, + { + "path": "raw/mneme/northwind-recall/causal.jsonl", + "sha256": "c7dc6c5f98b6183ea477761d519c2a242fa2fc53789a0accb2bff3fc4420a9d7" + }, + { + "path": "raw/mneme/northwind-recall/events.jsonl", + "sha256": "c595e083fef0a6d38a5fb2899ae1d5deccdb54f83b14aea38d919dc463622c2f" + }, + { + "path": "raw/world/causal.jsonl", + "sha256": "d328b0e34e72bd29e62426212c45aefe5a8a6833752a0e67aae533286f74b076" + }, + { + "path": "world/telemetry.json", + "sha256": "bafb415c913141e6eed1375938f70027f65b027e9837d958568182622d829eda" + }, + { + "path": "spawnfile-report.json", + "sha256": "a0890f4f559255d3c7b3cb02e8619d55380f460e7a3ae3406ff7a1fc2e5b4656" + } + ], + "engine": "scripted", + "world": { + "network_id": "office-psyche", + "rooms": [ + { + "network_id": "office-floor", + "room_id": "commons", + "members": ["eleanor", "sam", "mara"] + }, + { + "network_id": "eleanor_inner", + "room_id": "eleanor-council", + "members": ["eleanor", "eleanor-animus", "eleanor-shadow"] + } + ], + "places": { + "office": { "label": "Office", "kind": "room" }, + "home": { "label": "Home", "kind": "room" } + }, + "routes": { + "office_home": { + "from": "office", + "to": "home", + "travel_ticks": 2, + "direction": "bidirectional" + } + }, + "presence": { + "eleanor": "office", + "sam": "office", + "mara": "home" + } + } +} diff --git a/fixtures/observe/office-psyche-golden/observe/report.json b/fixtures/observe/office-psyche-golden/observe/report.json new file mode 100644 index 0000000..1a6dbed --- /dev/null +++ b/fixtures/observe/office-psyche-golden/observe/report.json @@ -0,0 +1,39 @@ +{ + "version": "simfile.observe.v1", + "run_id": "run-office-psyche-golden", + "contract_versions": { + "causal-event.v1": "noopolis.causal-event.v1", + "simfile.observe.v1": "simfile.observe.v1", + "simfile.run-manifest.v1": "simfile.run-manifest.v1" + }, + "participants": [ + "eleanor", + "eleanor-animus", + "eleanor-shadow", + "mara", + "sam" + ], + "agent_turns": { + "count": 8, + "sequence": [ + "eleanor", + "eleanor-animus", + "sam", + "eleanor-shadow", + "eleanor", + "eleanor", + "mara", + "eleanor" + ] + }, + "chains": { "complete": 46, "incomplete": [] }, + "memory": [ + { + "bank": "northwind-recall", + "events": 8, + "recalls": 2, + "memory_write_source": "events-fallback" + } + ], + "failures": [] +} diff --git a/fixtures/observe/office-psyche-golden/raw/daimon/eleanor-animus/causal.jsonl b/fixtures/observe/office-psyche-golden/raw/daimon/eleanor-animus/causal.jsonl new file mode 100644 index 0000000..87fdb46 --- /dev/null +++ b/fixtures/observe/office-psyche-golden/raw/daimon/eleanor-animus/causal.jsonl @@ -0,0 +1,3 @@ +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:wake-eleanor-animus-council:delivery.wake.accepted","emitter":{"system":"daimon","stream_id":"agent:eleanor-animus","seq":1},"type":"control.wake.accepted","principal_id":"system:moltnet","recorded_at":"2026-07-14T08:01:10.005Z","cause_event_ids":["moltnet:msg_33333333-3333-4333-8333-000000000001"],"payload":{"delivered_by":"moltnet","target_agent_id":"eleanor-animus","wake_kind":"message"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_33333333-3333-4333-8333-000000000001:turn.input.submitted","emitter":{"system":"daimon","stream_id":"agent:eleanor-animus","seq":2},"type":"turn.input.submitted","principal_id":"agent:eleanor-animus","recorded_at":"2026-07-14T08:01:10.010Z","cause_event_ids":["moltnet:msg_33333333-3333-4333-8333-000000000001","daimon:wake-eleanor-animus-council:delivery.wake.accepted"],"payload":{"input_content_sha256":"c6f847022a53484242c85d0f3fe53e837076614a4412deda56f84535e1949e0f","input_message_ids":["moltnet:msg_33333333-3333-4333-8333-000000000001"],"prompt_sha256":"fb8e3bc37d251337a40b392b47718384454fd1702f2735038a5d2662f886aa68","turn_id":"moltnet:msg_33333333-3333-4333-8333-000000000001"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_33333333-3333-4333-8333-000000000001:turn.output.completed","emitter":{"system":"daimon","stream_id":"agent:eleanor-animus","seq":3},"type":"turn.output.completed","principal_id":"agent:eleanor-animus","recorded_at":"2026-07-14T08:01:10.020Z","cause_event_ids":["daimon:moltnet:msg_33333333-3333-4333-8333-000000000001:turn.input.submitted"],"payload":{"output_sha256":"ed8ada6c98839961f63c0b9973ca2b75c3d4dd8ea4cdd580f14ba943e8311dee","turn_id":"moltnet:msg_33333333-3333-4333-8333-000000000001"}} diff --git a/fixtures/observe/office-psyche-golden/raw/daimon/eleanor-shadow/causal.jsonl b/fixtures/observe/office-psyche-golden/raw/daimon/eleanor-shadow/causal.jsonl new file mode 100644 index 0000000..6ae29fb --- /dev/null +++ b/fixtures/observe/office-psyche-golden/raw/daimon/eleanor-shadow/causal.jsonl @@ -0,0 +1,3 @@ +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:wake-eleanor-shadow-council:delivery.wake.accepted","emitter":{"system":"daimon","stream_id":"agent:eleanor-shadow","seq":1},"type":"control.wake.accepted","principal_id":"system:moltnet","recorded_at":"2026-07-14T08:01:20.005Z","cause_event_ids":["moltnet:msg_33333333-3333-4333-8333-000000000002"],"payload":{"delivered_by":"moltnet","target_agent_id":"eleanor-shadow","wake_kind":"message"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_33333333-3333-4333-8333-000000000002:turn.input.submitted","emitter":{"system":"daimon","stream_id":"agent:eleanor-shadow","seq":2},"type":"turn.input.submitted","principal_id":"agent:eleanor-shadow","recorded_at":"2026-07-14T08:01:20.010Z","cause_event_ids":["moltnet:msg_33333333-3333-4333-8333-000000000002","daimon:wake-eleanor-shadow-council:delivery.wake.accepted"],"payload":{"input_content_sha256":"ed8ada6c98839961f63c0b9973ca2b75c3d4dd8ea4cdd580f14ba943e8311dee","input_message_ids":["moltnet:msg_33333333-3333-4333-8333-000000000002"],"prompt_sha256":"5d3644d279d1fec5618399f4b676c4846214889e9dc176a8254791df4df1377f","turn_id":"moltnet:msg_33333333-3333-4333-8333-000000000002"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_33333333-3333-4333-8333-000000000002:turn.output.completed","emitter":{"system":"daimon","stream_id":"agent:eleanor-shadow","seq":3},"type":"turn.output.completed","principal_id":"agent:eleanor-shadow","recorded_at":"2026-07-14T08:01:20.020Z","cause_event_ids":["daimon:moltnet:msg_33333333-3333-4333-8333-000000000002:turn.input.submitted"],"payload":{"output_sha256":"bb5498397ac6f176d6b75acf06f995ccd3abbb4653c2784040eb88f76ef7b79e","turn_id":"moltnet:msg_33333333-3333-4333-8333-000000000002"}} diff --git a/fixtures/observe/office-psyche-golden/raw/daimon/eleanor/causal.jsonl b/fixtures/observe/office-psyche-golden/raw/daimon/eleanor/causal.jsonl new file mode 100644 index 0000000..8e18902 --- /dev/null +++ b/fixtures/observe/office-psyche-golden/raw/daimon/eleanor/causal.jsonl @@ -0,0 +1,12 @@ +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:wake-eleanor-office-seed:delivery.wake.accepted","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":1},"type":"control.wake.accepted","principal_id":"system:moltnet","recorded_at":"2026-07-14T08:00:30.005Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000001"],"payload":{"delivered_by":"moltnet","target_agent_id":"eleanor","wake_kind":"message"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_11111111-1111-4111-8111-000000000001:turn.input.submitted","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":2},"type":"turn.input.submitted","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:00:30.010Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000001","daimon:wake-eleanor-office-seed:delivery.wake.accepted"],"payload":{"input_content_sha256":"35542840072dc463579946d03924c8c8a0b3d3104d1e237b39d268d8f9bc9801","input_message_ids":["moltnet:msg_11111111-1111-4111-8111-000000000001"],"prompt_sha256":"d7e577844d16e99ed73893397a86479550ce281c039784dc26da30c37e0a7d5c","turn_id":"moltnet:msg_11111111-1111-4111-8111-000000000001"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_11111111-1111-4111-8111-000000000001:turn.output.completed","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":3},"type":"turn.output.completed","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:00:30.020Z","cause_event_ids":["daimon:moltnet:msg_11111111-1111-4111-8111-000000000001:turn.input.submitted"],"payload":{"output_sha256":"6bba28623186036495bdaa8bd9647c265c4e0601fe9def7f6c9e5460692c6784","turn_id":"moltnet:msg_11111111-1111-4111-8111-000000000001"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:wake-eleanor-sam-agreement:delivery.wake.accepted","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":4},"type":"control.wake.accepted","principal_id":"system:moltnet","recorded_at":"2026-07-14T08:01:05.004Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000003"],"payload":{"delivered_by":"moltnet","target_agent_id":"eleanor","wake_kind":"message"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_11111111-1111-4111-8111-000000000003:turn.input.submitted","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":5},"type":"turn.input.submitted","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:01:05.008Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000003","daimon:wake-eleanor-sam-agreement:delivery.wake.accepted","mneme:55555555-5555-4555-8555-000000000003","mneme:55555555-5555-4555-8555-000000000004"],"payload":{"input_content_sha256":"2746b1e262588547b52183634fc45360bca83303b0cd302b7c5eff7c383425da","input_message_ids":["moltnet:msg_11111111-1111-4111-8111-000000000003"],"prompt_sha256":"6e16521df0b3b677f2120315bfebe1eec5dba6385ae1b70368545c912cce9d02","turn_id":"moltnet:msg_11111111-1111-4111-8111-000000000003"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_11111111-1111-4111-8111-000000000003:turn.output.completed","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":6},"type":"turn.output.completed","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:01:05.020Z","cause_event_ids":["daimon:moltnet:msg_11111111-1111-4111-8111-000000000003:turn.input.submitted"],"payload":{"output_sha256":"c6f847022a53484242c85d0f3fe53e837076614a4412deda56f84535e1949e0f","turn_id":"moltnet:msg_11111111-1111-4111-8111-000000000003"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:wake-eleanor-shadow-risk:delivery.wake.accepted","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":7},"type":"control.wake.accepted","principal_id":"system:moltnet","recorded_at":"2026-07-14T08:01:30.005Z","cause_event_ids":["moltnet:msg_33333333-3333-4333-8333-000000000003"],"payload":{"delivered_by":"moltnet","target_agent_id":"eleanor","wake_kind":"message"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_33333333-3333-4333-8333-000000000003:turn.input.submitted","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":8},"type":"turn.input.submitted","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:01:30.010Z","cause_event_ids":["moltnet:msg_33333333-3333-4333-8333-000000000003","daimon:wake-eleanor-shadow-risk:delivery.wake.accepted"],"payload":{"input_content_sha256":"bb5498397ac6f176d6b75acf06f995ccd3abbb4653c2784040eb88f76ef7b79e","input_message_ids":["moltnet:msg_33333333-3333-4333-8333-000000000003"],"prompt_sha256":"bd05af387f57598851f221cef6ea3c6bc2e97ef95e31d53b9214d0dd1cba7f31","turn_id":"moltnet:msg_33333333-3333-4333-8333-000000000003"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_33333333-3333-4333-8333-000000000003:turn.output.completed","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":9},"type":"turn.output.completed","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:01:30.020Z","cause_event_ids":["daimon:moltnet:msg_33333333-3333-4333-8333-000000000003:turn.input.submitted"],"payload":{"output_sha256":"11a466b06f27bcbcc2435d0c1374f4b398162593feb5d1204ff0c2748e6f4ce0","turn_id":"moltnet:msg_33333333-3333-4333-8333-000000000003"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:wake-eleanor-home-question:delivery.wake.accepted","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":10},"type":"control.wake.accepted","principal_id":"system:moltnet","recorded_at":"2026-07-14T08:05:25.005Z","cause_event_ids":["moltnet:msg_22222222-2222-4222-8222-000000000002"],"payload":{"delivered_by":"moltnet","target_agent_id":"eleanor","wake_kind":"message"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_22222222-2222-4222-8222-000000000002:turn.input.submitted","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":11},"type":"turn.input.submitted","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:05:25.010Z","cause_event_ids":["moltnet:msg_22222222-2222-4222-8222-000000000002","daimon:wake-eleanor-home-question:delivery.wake.accepted"],"payload":{"input_content_sha256":"88a74725c08d7c1182a11f0492794652cb6d639497c3d84bd870bb456a2c64f9","input_message_ids":["moltnet:msg_22222222-2222-4222-8222-000000000002"],"prompt_sha256":"75989497ddaef57ca4b1ae83c5158ee46e8458312d3252ccdf3e250fc636131d","turn_id":"moltnet:msg_22222222-2222-4222-8222-000000000002"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_22222222-2222-4222-8222-000000000002:turn.output.completed","emitter":{"system":"daimon","stream_id":"agent:eleanor","seq":12},"type":"turn.output.completed","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:05:25.020Z","cause_event_ids":["daimon:moltnet:msg_22222222-2222-4222-8222-000000000002:turn.input.submitted"],"payload":{"output_sha256":"92ce5b3f620201c5e9e711d5f479e99d4462a2e3bae462d82413fdf3229c745e","turn_id":"moltnet:msg_22222222-2222-4222-8222-000000000002"}} diff --git a/fixtures/observe/office-psyche-golden/raw/daimon/mara/causal.jsonl b/fixtures/observe/office-psyche-golden/raw/daimon/mara/causal.jsonl new file mode 100644 index 0000000..a01df10 --- /dev/null +++ b/fixtures/observe/office-psyche-golden/raw/daimon/mara/causal.jsonl @@ -0,0 +1,3 @@ +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:wake-mara-home-recap:delivery.wake.accepted","emitter":{"system":"daimon","stream_id":"agent:mara","seq":1},"type":"control.wake.accepted","principal_id":"system:moltnet","recorded_at":"2026-07-14T08:05:10.005Z","cause_event_ids":["moltnet:msg_22222222-2222-4222-8222-000000000001"],"payload":{"delivered_by":"moltnet","target_agent_id":"mara","wake_kind":"message"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_22222222-2222-4222-8222-000000000001:turn.input.submitted","emitter":{"system":"daimon","stream_id":"agent:mara","seq":2},"type":"turn.input.submitted","principal_id":"agent:mara","recorded_at":"2026-07-14T08:05:10.010Z","cause_event_ids":["moltnet:msg_22222222-2222-4222-8222-000000000001","daimon:wake-mara-home-recap:delivery.wake.accepted"],"payload":{"input_content_sha256":"15ff7cb670658fe8fbda59c4da10150bf5da47a66b9ce43b1f6069724348106b","input_message_ids":["moltnet:msg_22222222-2222-4222-8222-000000000001"],"prompt_sha256":"7a60ab014105ee7f313ef34350a956231df23ae7703d7a2ae78379375ef05945","turn_id":"moltnet:msg_22222222-2222-4222-8222-000000000001"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_22222222-2222-4222-8222-000000000001:turn.output.completed","emitter":{"system":"daimon","stream_id":"agent:mara","seq":3},"type":"turn.output.completed","principal_id":"agent:mara","recorded_at":"2026-07-14T08:05:10.020Z","cause_event_ids":["daimon:moltnet:msg_22222222-2222-4222-8222-000000000001:turn.input.submitted"],"payload":{"output_sha256":"88a74725c08d7c1182a11f0492794652cb6d639497c3d84bd870bb456a2c64f9","turn_id":"moltnet:msg_22222222-2222-4222-8222-000000000001"}} diff --git a/fixtures/observe/office-psyche-golden/raw/daimon/sam/causal.jsonl b/fixtures/observe/office-psyche-golden/raw/daimon/sam/causal.jsonl new file mode 100644 index 0000000..b1ff6da --- /dev/null +++ b/fixtures/observe/office-psyche-golden/raw/daimon/sam/causal.jsonl @@ -0,0 +1,3 @@ +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:wake-sam-rollout-proposal:delivery.wake.accepted","emitter":{"system":"daimon","stream_id":"agent:sam","seq":1},"type":"control.wake.accepted","principal_id":"system:moltnet","recorded_at":"2026-07-14T08:00:45.005Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000002"],"payload":{"delivered_by":"moltnet","target_agent_id":"sam","wake_kind":"message"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_11111111-1111-4111-8111-000000000002:turn.input.submitted","emitter":{"system":"daimon","stream_id":"agent:sam","seq":2},"type":"turn.input.submitted","principal_id":"agent:sam","recorded_at":"2026-07-14T08:00:45.010Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000002","daimon:wake-sam-rollout-proposal:delivery.wake.accepted"],"payload":{"input_content_sha256":"6bba28623186036495bdaa8bd9647c265c4e0601fe9def7f6c9e5460692c6784","input_message_ids":["moltnet:msg_11111111-1111-4111-8111-000000000002"],"prompt_sha256":"100081e036e0cb69d7cebf05459ab9556fdf78afb2d36ff2e283e311134cdace","turn_id":"moltnet:msg_11111111-1111-4111-8111-000000000002"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"daimon:moltnet:msg_11111111-1111-4111-8111-000000000002:turn.output.completed","emitter":{"system":"daimon","stream_id":"agent:sam","seq":3},"type":"turn.output.completed","principal_id":"agent:sam","recorded_at":"2026-07-14T08:00:45.020Z","cause_event_ids":["daimon:moltnet:msg_11111111-1111-4111-8111-000000000002:turn.input.submitted"],"payload":{"output_sha256":"2746b1e262588547b52183634fc45360bca83303b0cd302b7c5eff7c383425da","turn_id":"moltnet:msg_11111111-1111-4111-8111-000000000002"}} diff --git a/fixtures/observe/office-psyche-golden/raw/mneme/northwind-recall/causal.jsonl b/fixtures/observe/office-psyche-golden/raw/mneme/northwind-recall/causal.jsonl new file mode 100644 index 0000000..7608fd2 --- /dev/null +++ b/fixtures/observe/office-psyche-golden/raw/mneme/northwind-recall/causal.jsonl @@ -0,0 +1,5 @@ +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"mneme:55555555-5555-4555-8555-000000000001","emitter":{"system":"mneme","stream_id":"memory:eleanor","seq":1},"type":"memory.recall.mode","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:00:30.006Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000001"],"payload":{"mode":"on","wake_event_id":"moltnet:msg_11111111-1111-4111-8111-000000000001","injected_count":0,"degenerate":false}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"mneme:55555555-5555-4555-8555-000000000002","emitter":{"system":"mneme","stream_id":"memory:sam","seq":1},"type":"memory.recall.mode","principal_id":"agent:sam","recorded_at":"2026-07-14T08:00:45.006Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000002"],"payload":{"mode":"on","wake_event_id":"moltnet:msg_11111111-1111-4111-8111-000000000002","injected_count":0,"degenerate":false}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"mneme:55555555-5555-4555-8555-000000000003","emitter":{"system":"mneme","stream_id":"memory:eleanor","seq":2},"type":"memory.recalled","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:01:05.006Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000003"],"payload":{"memory_id":"evt_44444444-4444-4444-8444-000000000002","revision_id":"evt_44444444-4444-4444-8444-000000000002","scope":"agent:eleanor/scope:room/qualifier:office-psyche:office","content_sha256":"03aeb21443dadfab558eaae0f3866c87c82e59d2821acba1d74c59c55dee2f73"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"mneme:55555555-5555-4555-8555-000000000004","emitter":{"system":"mneme","stream_id":"memory:eleanor","seq":3},"type":"memory.recalled","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:01:05.007Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000003"],"payload":{"memory_id":"evt_44444444-4444-4444-8444-000000000001","revision_id":"evt_44444444-4444-4444-8444-000000000001","scope":"agent:eleanor/scope:room/qualifier:office-psyche:office","content_sha256":"b2856ddca62e5cfff36837a3317cb2cf06c712a9c3dc09b95c4cf843978e9a81"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"mneme:55555555-5555-4555-8555-000000000005","emitter":{"system":"mneme","stream_id":"memory:eleanor","seq":4},"type":"memory.recall.mode","principal_id":"agent:eleanor","recorded_at":"2026-07-14T08:01:05.007Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000003"],"payload":{"mode":"on","wake_event_id":"moltnet:msg_11111111-1111-4111-8111-000000000003","injected_count":2,"degenerate":false}} diff --git a/fixtures/observe/office-psyche-golden/raw/mneme/northwind-recall/events.jsonl b/fixtures/observe/office-psyche-golden/raw/mneme/northwind-recall/events.jsonl new file mode 100644 index 0000000..62aca46 --- /dev/null +++ b/fixtures/observe/office-psyche-golden/raw/mneme/northwind-recall/events.jsonl @@ -0,0 +1,8 @@ +{"id":"evt_44444444-4444-4444-8444-000000000001","type":"memory.claimed","createdAt":"2026-07-14T08:00:30.011Z","principal":{"agentId":"eleanor","scope":"room","qualifier":"office-psyche:office"},"scope":"agent:eleanor/scope:room/qualifier:office-psyche:office","visibility":"room","source":"spawnfile:team:northwind-recall","content":{"kind":"text","text":"Wake request moltnet:msg_11111111-1111-4111-8111-000000000001 from Office Director: finalize the Northwind rollout with Sam today and leave with a target date both can stand behind."},"tags":["northwind","rollout","decision","office","eleanor"],"entities":["Northwind","eleanor","sam","office"],"sensitivity":"normal","parentEventIds":[],"seq":1,"checksum":"b2856ddca62e5cfff36837a3317cb2cf06c712a9c3dc09b95c4cf843978e9a81"} +{"id":"evt_44444444-4444-4444-8444-000000000002","type":"memory.observed","createdAt":"2026-07-14T08:00:45.002Z","principal":{"agentId":"eleanor","scope":"room","qualifier":"office-psyche:office"},"scope":"agent:eleanor/scope:room/qualifier:office-psyche:office","visibility":"room","source":"spawnfile:team:northwind-recall","content":{"kind":"text","text":"Agent output in moltnet:msg_11111111-1111-4111-8111-000000000002: proposed Tuesday, July 21 for Northwind, with Monday reserved for the rollback rehearsal."},"tags":["northwind","rollout","july-21","rollback-rehearsal","proposal"],"entities":["Northwind","eleanor","sam"],"sensitivity":"normal","parentEventIds":["evt_44444444-4444-4444-8444-000000000001"],"seq":2,"checksum":"03aeb21443dadfab558eaae0f3866c87c82e59d2821acba1d74c59c55dee2f73"} +{"id":"evt_44444444-4444-4444-8444-000000000003","type":"memory.claimed","createdAt":"2026-07-14T08:00:45.011Z","principal":{"agentId":"sam","scope":"room","qualifier":"office-psyche:office"},"scope":"agent:sam/scope:room/qualifier:office-psyche:office","visibility":"room","source":"spawnfile:team:northwind-recall","content":{"kind":"text","text":"Wake request moltnet:msg_11111111-1111-4111-8111-000000000002 from Eleanor: Northwind should go live July 21 after a Monday rollback rehearsal; delivery support must confirm."},"tags":["northwind","rollout","july-21","rollback-rehearsal","sam"],"entities":["Northwind","eleanor","sam"],"sensitivity":"normal","parentEventIds":[],"seq":1,"checksum":"40aa841b8125670428f0c6a0cbc1525f7f4c5799df6ca936f07c9f31808e5080"} +{"id":"evt_44444444-4444-4444-8444-000000000004","type":"memory.observed","createdAt":"2026-07-14T08:01:05.002Z","principal":{"agentId":"sam","scope":"room","qualifier":"office-psyche:office"},"scope":"agent:sam/scope:room/qualifier:office-psyche:office","visibility":"room","source":"spawnfile:team:northwind-recall","content":{"kind":"text","text":"Agent output in moltnet:msg_11111111-1111-4111-8111-000000000003: delivery agreed to July 21 if Monday's rollback rehearsal is a hard gate."},"tags":["northwind","agreement","july-21","hard-gate","delivery"],"entities":["Northwind","eleanor","sam"],"sensitivity":"normal","parentEventIds":["evt_44444444-4444-4444-8444-000000000003"],"seq":2,"checksum":"69bff63a7f40b173a712a072df2cf3a2e5bb432cf36c59a2f8bdc565d3021175"} +{"id":"evt_44444444-4444-4444-8444-000000000005","type":"memory.claimed","createdAt":"2026-07-14T08:01:05.009Z","principal":{"agentId":"eleanor","scope":"room","qualifier":"office-psyche:office"},"scope":"agent:eleanor/scope:room/qualifier:office-psyche:office","visibility":"room","source":"spawnfile:team:northwind-recall","content":{"kind":"text","text":"Wake request moltnet:msg_11111111-1111-4111-8111-000000000003 from Sam: delivery supports July 21 if Eleanor owns Monday's rollback rehearsal as a hard gate."},"tags":["northwind","agreement","ownership","hard-gate","eleanor"],"entities":["Northwind","eleanor","sam"],"sensitivity":"normal","parentEventIds":["evt_44444444-4444-4444-8444-000000000001","evt_44444444-4444-4444-8444-000000000002"],"seq":3,"checksum":"d9af738089150061bf260fc3f4a4e6a0594c44d2872e9d59fe0f9755865a5278"} +{"id":"evt_44444444-4444-4444-8444-000000000006","type":"memory.recalled","createdAt":"2026-07-14T08:01:05.022Z","principal":{"agentId":"eleanor","scope":"room","qualifier":"office-psyche:office"},"scope":"agent:eleanor/scope:room/qualifier:office-psyche:office","visibility":"room","source":"spawnfile:team:northwind-recall","content":{"kind":"text","text":"Recalled evt_44444444-4444-4444-8444-000000000002: Eleanor proposed July 21 with a Monday rollback rehearsal. Decision=used raw (agent:eleanor/scope:room/qualifier:office-psyche:office)"},"tags":["northwind","memory","recall","proposal","july-21"],"entities":["Northwind","eleanor","evt_44444444-4444-4444-8444-000000000002"],"sensitivity":"normal","parentEventIds":["evt_44444444-4444-4444-8444-000000000002","evt_44444444-4444-4444-8444-000000000005"],"seq":4,"checksum":"1b582c8c72716e7558370eb92d6a611622f129ad75192d42f923188f40324abc"} +{"id":"evt_44444444-4444-4444-8444-000000000007","type":"memory.recalled","createdAt":"2026-07-14T08:01:05.023Z","principal":{"agentId":"eleanor","scope":"room","qualifier":"office-psyche:office"},"scope":"agent:eleanor/scope:room/qualifier:office-psyche:office","visibility":"room","source":"spawnfile:team:northwind-recall","content":{"kind":"text","text":"Recalled evt_44444444-4444-4444-8444-000000000001: the director asked for a decision Eleanor and Sam could stand behind. Decision=used raw (agent:eleanor/scope:room/qualifier:office-psyche:office)"},"tags":["northwind","memory","recall","decision","accountability"],"entities":["Northwind","eleanor","sam","evt_44444444-4444-4444-8444-000000000001"],"sensitivity":"normal","parentEventIds":["evt_44444444-4444-4444-8444-000000000001","evt_44444444-4444-4444-8444-000000000005"],"seq":5,"checksum":"2e8e16e276efb5b93fbcb770634ae3ec62fb9768a26af20ca595efe7be663be5"} +{"id":"evt_44444444-4444-4444-8444-000000000008","type":"memory.observed","createdAt":"2026-07-14T08:01:50.002Z","principal":{"agentId":"eleanor","scope":"room","qualifier":"office-psyche:office"},"scope":"agent:eleanor/scope:room/qualifier:office-psyche:office","visibility":"room","source":"spawnfile:team:northwind-recall","content":{"kind":"text","text":"Decision recorded in moltnet:msg_11111111-1111-4111-8111-000000000004: Northwind goes live July 21; Monday is a published hard gate, and failure produces a blame-free no-go."},"tags":["northwind","decision","july-21","hard-gate","no-go"],"entities":["Northwind","eleanor","sam"],"sensitivity":"normal","parentEventIds":["evt_44444444-4444-4444-8444-000000000005","evt_44444444-4444-4444-8444-000000000006","evt_44444444-4444-4444-8444-000000000007"],"seq":6,"checksum":"12a44ca0e6c92872a259a2eb7dcf24bf6960436153bc492634753637df7ca2e1"} diff --git a/fixtures/observe/office-psyche-golden/raw/moltnet/causal.jsonl b/fixtures/observe/office-psyche-golden/raw/moltnet/causal.jsonl new file mode 100644 index 0000000..8c97308 --- /dev/null +++ b/fixtures/observe/office-psyche-golden/raw/moltnet/causal.jsonl @@ -0,0 +1,11 @@ +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"moltnet:msg_11111111-1111-4111-8111-000000000001","emitter":{"system":"moltnet","stream_id":"network:office-psyche","seq":1},"type":"message.accepted","principal_id":"system:moltnet.anonymous","recorded_at":"2026-07-14T08:00:30.001Z","cause_event_ids":[],"payload":{"message_id":"msg_11111111-1111-4111-8111-000000000001","target":{"kind":"room","room_id":"office"},"content_sha256":"62072d22fbc6a64317f5358d8ced3e2dcd938322372d1b9b2502df2ae597d0d0","policy_decision":"accepted"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"moltnet:msg_11111111-1111-4111-8111-000000000002","emitter":{"system":"moltnet","stream_id":"network:office-psyche","seq":2},"type":"message.accepted","principal_id":"system:moltnet.anonymous","recorded_at":"2026-07-14T08:00:45.001Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000001"],"payload":{"message_id":"msg_11111111-1111-4111-8111-000000000002","target":{"kind":"room","room_id":"office"},"content_sha256":"43bab79c22a1540ebb67e0928ca92c9fa0dce39ca17356d9d58545da2ee30f99","policy_decision":"accepted"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"moltnet:msg_11111111-1111-4111-8111-000000000003","emitter":{"system":"moltnet","stream_id":"network:office-psyche","seq":3},"type":"message.accepted","principal_id":"system:moltnet.anonymous","recorded_at":"2026-07-14T08:01:05.001Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000002"],"payload":{"message_id":"msg_11111111-1111-4111-8111-000000000003","target":{"kind":"room","room_id":"office"},"content_sha256":"b8c5b46b325a59078410492d84fb11b345a02146297e4a426628abd950504c6c","policy_decision":"accepted"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"moltnet:msg_33333333-3333-4333-8333-000000000001","emitter":{"system":"moltnet","stream_id":"network:eleanor_inner","seq":1},"type":"message.accepted","principal_id":"system:moltnet.anonymous","recorded_at":"2026-07-14T08:01:10.001Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000003"],"payload":{"message_id":"msg_33333333-3333-4333-8333-000000000001","target":{"kind":"room","room_id":"eleanor-council"},"content_sha256":"95207a034e965ea8101fe523902aa29d2a9051f066a3adf5fcca2b1389747eb8","policy_decision":"accepted"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"moltnet:msg_33333333-3333-4333-8333-000000000002","emitter":{"system":"moltnet","stream_id":"network:eleanor_inner","seq":2},"type":"message.accepted","principal_id":"system:moltnet.anonymous","recorded_at":"2026-07-14T08:01:20.001Z","cause_event_ids":["moltnet:msg_33333333-3333-4333-8333-000000000001"],"payload":{"message_id":"msg_33333333-3333-4333-8333-000000000002","target":{"kind":"room","room_id":"eleanor-council"},"content_sha256":"68dedc5b3460392d95bebc5b5438e7a608fe1720b875a940ab738cd862c6e159","policy_decision":"accepted"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"moltnet:msg_33333333-3333-4333-8333-000000000003","emitter":{"system":"moltnet","stream_id":"network:eleanor_inner","seq":3},"type":"message.accepted","principal_id":"system:moltnet.anonymous","recorded_at":"2026-07-14T08:01:30.001Z","cause_event_ids":["moltnet:msg_33333333-3333-4333-8333-000000000002"],"payload":{"message_id":"msg_33333333-3333-4333-8333-000000000003","target":{"kind":"room","room_id":"eleanor-council"},"content_sha256":"2b3408656cf45252c1e621a4a51ac3f5d11eb891c4d0ac96ae362c6ae13d304c","policy_decision":"accepted"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"moltnet:msg_33333333-3333-4333-8333-000000000004","emitter":{"system":"moltnet","stream_id":"network:eleanor_inner","seq":4},"type":"message.accepted","principal_id":"system:moltnet.anonymous","recorded_at":"2026-07-14T08:01:40.001Z","cause_event_ids":["moltnet:msg_33333333-3333-4333-8333-000000000003"],"payload":{"message_id":"msg_33333333-3333-4333-8333-000000000004","target":{"kind":"room","room_id":"eleanor-council"},"content_sha256":"307e615835ab85d16d06b663f37df7ad76c26acf1c0fb6f50c1c8cc114c3d8fb","policy_decision":"accepted"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"moltnet:msg_11111111-1111-4111-8111-000000000004","emitter":{"system":"moltnet","stream_id":"network:office-psyche","seq":4},"type":"message.accepted","principal_id":"system:moltnet.anonymous","recorded_at":"2026-07-14T08:01:50.001Z","cause_event_ids":["moltnet:msg_11111111-1111-4111-8111-000000000003","moltnet:msg_33333333-3333-4333-8333-000000000004"],"payload":{"message_id":"msg_11111111-1111-4111-8111-000000000004","target":{"kind":"room","room_id":"office"},"content_sha256":"ef649624e7eff2d4950d7121056c3b02fcc19f33715641e779c584e4ac9397f0","policy_decision":"accepted"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"moltnet:msg_22222222-2222-4222-8222-000000000001","emitter":{"system":"moltnet","stream_id":"network:office-psyche","seq":5},"type":"message.accepted","principal_id":"system:moltnet.anonymous","recorded_at":"2026-07-14T08:05:10.001Z","cause_event_ids":["simfile:run-office-psyche-golden:6"],"payload":{"message_id":"msg_22222222-2222-4222-8222-000000000001","target":{"kind":"room","room_id":"home"},"content_sha256":"659c749383d854f51d6cb5f58fa7078180c42187ba0eed68af7c00251c2866a3","policy_decision":"accepted"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"moltnet:msg_22222222-2222-4222-8222-000000000002","emitter":{"system":"moltnet","stream_id":"network:office-psyche","seq":6},"type":"message.accepted","principal_id":"system:moltnet.anonymous","recorded_at":"2026-07-14T08:05:25.001Z","cause_event_ids":["moltnet:msg_22222222-2222-4222-8222-000000000001"],"payload":{"message_id":"msg_22222222-2222-4222-8222-000000000002","target":{"kind":"room","room_id":"home"},"content_sha256":"2e50579cf276541b0d2aac8f74287fa15f09d37cc9957f58838ee7ca3581a088","policy_decision":"accepted"}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"moltnet:msg_22222222-2222-4222-8222-000000000003","emitter":{"system":"moltnet","stream_id":"network:office-psyche","seq":7},"type":"message.accepted","principal_id":"system:moltnet.anonymous","recorded_at":"2026-07-14T08:05:40.001Z","cause_event_ids":["moltnet:msg_22222222-2222-4222-8222-000000000002"],"payload":{"message_id":"msg_22222222-2222-4222-8222-000000000003","target":{"kind":"room","room_id":"home"},"content_sha256":"addd12f98819152fe48191f31e8be91d4de920046fc2357fb69d2d1873bf561d","policy_decision":"accepted"}} diff --git a/fixtures/observe/office-psyche-golden/raw/moltnet/transcript.json b/fixtures/observe/office-psyche-golden/raw/moltnet/transcript.json new file mode 100644 index 0000000..042f0e3 --- /dev/null +++ b/fixtures/observe/office-psyche-golden/raw/moltnet/transcript.json @@ -0,0 +1,178 @@ +{ + "seedMessageText": "@eleanor Finalize the Northwind client rollout with Sam today: choose the go-live date and leave with a decision we can stand behind.", + "transcript": [ + { + "id": "msg_11111111-1111-4111-8111-000000000001", + "network_id": "office-psyche", + "origin": { "network_id": "office-psyche", "message_id": "msg_11111111-1111-4111-8111-000000000001" }, + "target": { "kind": "room", "room_id": "office" }, + "from": { + "type": "human", + "id": "operator", + "name": "Office Director", + "network_id": "office-psyche", + "fqid": "molt://office-psyche/agents/operator" + }, + "parts": [{ "kind": "text", "text": "@eleanor Finalize the Northwind client rollout with Sam today: choose the go-live date and leave with a decision we can stand behind." }], + "mentions": ["molt://office-psyche/agents/eleanor"], + "created_at": "2026-07-14T08:00:30.000Z" + }, + { + "id": "msg_11111111-1111-4111-8111-000000000002", + "network_id": "office-psyche", + "origin": { "network_id": "office-psyche", "message_id": "msg_11111111-1111-4111-8111-000000000002" }, + "target": { "kind": "room", "room_id": "office" }, + "from": { + "type": "agent", + "id": "eleanor", + "name": "eleanor", + "network_id": "office-psyche", + "fqid": "molt://office-psyche/agents/eleanor" + }, + "parts": [{ "kind": "text", "text": "I propose Tuesday, July 21 for Northwind's client rollout, with Monday reserved for the rollback rehearsal. @sam can delivery support that date?" }], + "mentions": ["molt://office-psyche/agents/sam"], + "created_at": "2026-07-14T08:00:45.000Z" + }, + { + "id": "msg_11111111-1111-4111-8111-000000000003", + "network_id": "office-psyche", + "origin": { "network_id": "office-psyche", "message_id": "msg_11111111-1111-4111-8111-000000000003" }, + "target": { "kind": "room", "room_id": "office" }, + "from": { + "type": "agent", + "id": "sam", + "name": "sam", + "network_id": "office-psyche", + "fqid": "molt://office-psyche/agents/sam" + }, + "parts": [{ "kind": "text", "text": "Delivery can support July 21 if we make Monday's rollback rehearsal a hard gate. @eleanor I'm agreed; lock it once you're comfortable owning that condition." }], + "mentions": ["molt://office-psyche/agents/eleanor"], + "created_at": "2026-07-14T08:01:05.000Z" + }, + { + "id": "msg_33333333-3333-4333-8333-000000000001", + "network_id": "eleanor_inner", + "origin": { "network_id": "eleanor_inner", "message_id": "msg_33333333-3333-4333-8333-000000000001" }, + "target": { "kind": "room", "room_id": "eleanor-council" }, + "from": { + "type": "agent", + "id": "eleanor", + "name": "eleanor", + "network_id": "eleanor_inner", + "fqid": "molt://eleanor_inner/agents/eleanor" + }, + "parts": [{ "kind": "text", "text": "@eleanor-animus Sam will support July 21 if I own the rehearsal gate. I want the council's honest read before I commit." }], + "mentions": ["molt://eleanor_inner/agents/eleanor-animus"], + "created_at": "2026-07-14T08:01:10.000Z" + }, + { + "id": "msg_33333333-3333-4333-8333-000000000002", + "network_id": "eleanor_inner", + "origin": { "network_id": "eleanor_inner", "message_id": "msg_33333333-3333-4333-8333-000000000002" }, + "target": { "kind": "room", "room_id": "eleanor-council" }, + "from": { + "type": "agent", + "id": "eleanor-animus", + "name": "eleanor-animus", + "network_id": "eleanor_inner", + "fqid": "molt://eleanor_inner/agents/eleanor-animus" + }, + "parts": [{ "kind": "text", "text": "Commit. The date creates focus, and owning the gate is leadership rather than recklessness. @eleanor-shadow name the risk plainly." }], + "mentions": ["molt://eleanor_inner/agents/eleanor-shadow"], + "created_at": "2026-07-14T08:01:20.000Z" + }, + { + "id": "msg_33333333-3333-4333-8333-000000000003", + "network_id": "eleanor_inner", + "origin": { "network_id": "eleanor_inner", "message_id": "msg_33333333-3333-4333-8333-000000000003" }, + "target": { "kind": "room", "room_id": "eleanor-council" }, + "from": { + "type": "agent", + "id": "eleanor-shadow", + "name": "eleanor-shadow", + "network_id": "eleanor_inner", + "fqid": "molt://eleanor_inner/agents/eleanor-shadow" + }, + "parts": [{ "kind": "text", "text": "The risk is pretending the rehearsal cannot fail. If it slips, the team may hide bad news to protect your promise. @eleanor make the no-go path explicit." }], + "mentions": ["molt://eleanor_inner/agents/eleanor"], + "created_at": "2026-07-14T08:01:30.000Z" + }, + { + "id": "msg_33333333-3333-4333-8333-000000000004", + "network_id": "eleanor_inner", + "origin": { "network_id": "eleanor_inner", "message_id": "msg_33333333-3333-4333-8333-000000000004" }, + "target": { "kind": "room", "room_id": "eleanor-council" }, + "from": { + "type": "agent", + "id": "eleanor", + "name": "eleanor", + "network_id": "eleanor_inner", + "fqid": "molt://eleanor_inner/agents/eleanor" + }, + "parts": [{ "kind": "text", "text": "Resolved: commit to July 21, publish the rehearsal criteria, and call no-go without blame if Monday fails. Courage gets a boundary; fear gets a voice." }], + "created_at": "2026-07-14T08:01:40.000Z" + }, + { + "id": "msg_11111111-1111-4111-8111-000000000004", + "network_id": "office-psyche", + "origin": { "network_id": "office-psyche", "message_id": "msg_11111111-1111-4111-8111-000000000004" }, + "target": { "kind": "room", "room_id": "office" }, + "from": { + "type": "agent", + "id": "eleanor", + "name": "eleanor", + "network_id": "office-psyche", + "fqid": "molt://office-psyche/agents/eleanor" + }, + "parts": [{ "kind": "text", "text": "Locked: Northwind goes live Tuesday, July 21. Monday's rollback rehearsal is the hard gate, and a failed gate means a blame-free no-go. I'll publish the criteria today." }], + "created_at": "2026-07-14T08:01:50.000Z" + }, + { + "id": "msg_22222222-2222-4222-8222-000000000001", + "network_id": "office-psyche", + "origin": { "network_id": "office-psyche", "message_id": "msg_22222222-2222-4222-8222-000000000001" }, + "target": { "kind": "room", "room_id": "home" }, + "from": { + "type": "agent", + "id": "eleanor", + "name": "eleanor", + "network_id": "office-psyche", + "fqid": "molt://office-psyche/agents/eleanor" + }, + "parts": [{ "kind": "text", "text": "@mara We set Northwind's rollout for July 21. I agreed to make Monday's rollback rehearsal a real gate, not a ceremony, and the commute finally let that decision settle." }], + "mentions": ["molt://office-psyche/agents/mara"], + "created_at": "2026-07-14T08:05:10.000Z" + }, + { + "id": "msg_22222222-2222-4222-8222-000000000002", + "network_id": "office-psyche", + "origin": { "network_id": "office-psyche", "message_id": "msg_22222222-2222-4222-8222-000000000002" }, + "target": { "kind": "room", "room_id": "home" }, + "from": { + "type": "agent", + "id": "mara", + "name": "mara", + "network_id": "office-psyche", + "fqid": "molt://office-psyche/agents/mara" + }, + "parts": [{ "kind": "text", "text": "That sounds firm without being brittle. @eleanor what changed between wanting the date and being ready to own the no-go?" }], + "mentions": ["molt://office-psyche/agents/eleanor"], + "created_at": "2026-07-14T08:05:25.000Z" + }, + { + "id": "msg_22222222-2222-4222-8222-000000000003", + "network_id": "office-psyche", + "origin": { "network_id": "office-psyche", "message_id": "msg_22222222-2222-4222-8222-000000000003" }, + "target": { "kind": "room", "room_id": "home" }, + "from": { + "type": "agent", + "id": "eleanor", + "name": "eleanor", + "network_id": "office-psyche", + "fqid": "molt://office-psyche/agents/eleanor" + }, + "parts": [{ "kind": "text", "text": "I stopped treating doubt as a veto. It showed me the failure mode, so I could name the boundary and still commit. Now I can leave the work at work." }], + "created_at": "2026-07-14T08:05:40.000Z" + } + ] +} diff --git a/fixtures/observe/office-psyche-golden/raw/world/causal.jsonl b/fixtures/observe/office-psyche-golden/raw/world/causal.jsonl new file mode 100644 index 0000000..a49c102 --- /dev/null +++ b/fixtures/observe/office-psyche-golden/raw/world/causal.jsonl @@ -0,0 +1,6 @@ +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"simfile:run-office-psyche-golden:1","emitter":{"system":"simfile","stream_id":"world","seq":1},"type":"presence.arrived","principal_id":"system:simfile.world","recorded_at":"1970-01-01T00:00:00.000Z","cause_event_ids":[],"payload":{"sim_time":0,"provenance":"mechanical","actor":"eleanor","target":"office","scope":"office","agent":"eleanor","place":"office","tick":0}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"simfile:run-office-psyche-golden:2","emitter":{"system":"simfile","stream_id":"world","seq":2},"type":"presence.arrived","principal_id":"system:simfile.world","recorded_at":"1970-01-01T00:00:00.000Z","cause_event_ids":[],"payload":{"sim_time":0,"provenance":"mechanical","actor":"mara","target":"home","scope":"home","agent":"mara","place":"home","tick":0}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"simfile:run-office-psyche-golden:3","emitter":{"system":"simfile","stream_id":"world","seq":3},"type":"presence.arrived","principal_id":"system:simfile.world","recorded_at":"1970-01-01T00:00:00.000Z","cause_event_ids":[],"payload":{"sim_time":0,"provenance":"mechanical","actor":"sam","target":"office","scope":"office","agent":"sam","place":"office","tick":0}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"simfile:run-office-psyche-golden:4","emitter":{"system":"simfile","stream_id":"world","seq":4},"type":"presence.left","principal_id":"system:simfile.world","recorded_at":"1970-01-01T00:03:00.000Z","cause_event_ids":["simfile:run-office-psyche-golden:1"],"payload":{"sim_time":180,"provenance":"mechanical","actor":"eleanor","target":"office","scope":"office","agent":"eleanor","place":"office","tick":3}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"simfile:run-office-psyche-golden:5","emitter":{"system":"simfile","stream_id":"world","seq":5},"type":"rule.fired","principal_id":"system:simfile.world","recorded_at":"1970-01-01T00:04:00.000Z","cause_event_ids":["simfile:run-office-psyche-golden:4"],"payload":{"sim_time":240,"provenance":"mechanical","actor":"office_home","target":"office","scope":"office","rule":"commute-progress","tick":4}} +{"version":"noopolis.causal-event.v1","run_id":"run-office-psyche-golden","event_id":"simfile:run-office-psyche-golden:6","emitter":{"system":"simfile","stream_id":"world","seq":6},"type":"presence.arrived","principal_id":"system:simfile.world","recorded_at":"1970-01-01T00:05:00.000Z","cause_event_ids":["simfile:run-office-psyche-golden:4"],"payload":{"sim_time":300,"provenance":"mechanical","actor":"eleanor","target":"home","scope":"home","agent":"eleanor","place":"home","tick":5}} diff --git a/fixtures/observe/office-psyche-golden/spawnfile-report.json b/fixtures/observe/office-psyche-golden/spawnfile-report.json new file mode 100644 index 0000000..12f1569 --- /dev/null +++ b/fixtures/observe/office-psyche-golden/spawnfile-report.json @@ -0,0 +1,46 @@ +{ + "compile_fingerprint": "sf1:officepsychegolden", + "container": { + "moltnet": { + "server_plans": [ + { + "id": "office-psyche-office-floor", + "network_id": "office-floor", + "rooms": [ + { "id": "commons", "members": ["eleanor", "sam", "mara"] } + ] + }, + { + "id": "eleanor-mind-eleanor_inner", + "network_id": "eleanor_inner", + "rooms": [ + { "id": "eleanor-council", "members": ["eleanor", "eleanor-animus", "eleanor-shadow"] } + ] + } + ] + } + }, + "nodes": [ + { "id": "team:eleanor-mind", "kind": "team" }, + { + "id": "agent:eleanor", + "kind": "agent", + "active_environments": { + "moltnet": { + "office-floor": { + "rooms": { + "commons": { "member_slot": "eleanor-mind", "team_id": "office-psyche--eleanor-mind" } + } + }, + "eleanor_inner": { + "rooms": { + "eleanor-council": { "member_slot": "eleanor", "team_id": "eleanor-mind" } + } + } + } + } + } + ], + "project_name": "office-psyche", + "spawnfile_version": "0.1" +} diff --git a/fixtures/observe/office-psyche-golden/spawnfile/export-index.json b/fixtures/observe/office-psyche-golden/spawnfile/export-index.json new file mode 100644 index 0000000..f8ef798 --- /dev/null +++ b/fixtures/observe/office-psyche-golden/spawnfile/export-index.json @@ -0,0 +1,7 @@ +{ + "deployment": "office-psyche-scripted-golden", + "exported_at": "2026-07-14T08:00:00.000Z", + "files": [], + "run_id": "run-office-psyche-golden", + "version": "spawnfile.export-index.v1" +} diff --git a/fixtures/observe/office-psyche-golden/world/telemetry.json b/fixtures/observe/office-psyche-golden/world/telemetry.json new file mode 100644 index 0000000..e9f5baf --- /dev/null +++ b/fixtures/observe/office-psyche-golden/world/telemetry.json @@ -0,0 +1,48 @@ +{ + "run_id": "run-office-psyche-golden", + "samples": [ + { + "tick": 0, + "sim_time": 0, + "variables": {}, + "occupancy": { "home": ["mara"], "office": ["eleanor", "sam"] }, + "transit": [] + }, + { + "tick": 1, + "sim_time": 60, + "variables": {}, + "occupancy": { "home": ["mara"], "office": ["eleanor", "sam"] }, + "transit": [] + }, + { + "tick": 2, + "sim_time": 120, + "variables": {}, + "occupancy": { "home": ["mara"], "office": ["eleanor", "sam"] }, + "transit": [] + }, + { + "tick": 3, + "sim_time": 180, + "variables": {}, + "occupancy": { "home": ["mara"], "office": ["sam"] }, + "transit": [{ "agent": "eleanor", "from": "office", "to": "home", "ticksRemaining": 2 }] + }, + { + "tick": 4, + "sim_time": 240, + "variables": {}, + "occupancy": { "home": ["mara"], "office": ["sam"] }, + "transit": [{ "agent": "eleanor", "from": "office", "to": "home", "ticksRemaining": 1 }] + }, + { + "tick": 5, + "sim_time": 300, + "variables": {}, + "occupancy": { "home": ["eleanor", "mara"], "office": ["sam"] }, + "transit": [] + } + ], + "version": "simfile.telemetry.v1" +} diff --git a/fixtures/sims/README.md b/fixtures/sims/README.md new file mode 100644 index 0000000..f600a5a --- /dev/null +++ b/fixtures/sims/README.md @@ -0,0 +1,17 @@ +# Simulation fixtures + +This directory contains the small, maintained scenarios used by Simfile's +documentation and automated tests. + +| Fixture | Purpose | +| --- | --- | +| `office-sim` | Minimal multi-agent organization example. | +| `office-secret-v0` | Seeded-memory and observation example. | +| `office-pressure-v0` | Deterministic world-variable example. | +| `jungian-daimon-org` | Nested-team and membrane example. | +| `public-dynamics-contract` | Public dynamics API compile and runtime contract. | + +Experimental simulations belong on a feature branch until they have a bounded +contract, an automated test, and a documented reason to remain in the reference +repository. Model-specific copies and captured experiments are not reference +fixtures. diff --git a/fixtures/sims/jungian-daimon-org-grok/org/Spawnfile b/fixtures/sims/jungian-daimon-org-grok/org/Spawnfile deleted file mode 100644 index 27f5951..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/Spawnfile +++ /dev/null @@ -1,40 +0,0 @@ -spawnfile_version: "0.1" -kind: team -name: jungian-daimon-org-grok -description: "Two Jungian selves (real grok voices) with nested council teams; representative consults its inner council before answering the floor." - -shared: - workspace: - docs: - system: TEAM.md - -members: - - id: luna - ref: ./teams/luna - - id: selene - ref: ./teams/selene - -mode: hierarchical -lead: luna - -networks: - - id: psyche-floor - name: Jungian Floor - provider: moltnet - server: - mode: managed - listen: - bind: 0.0.0.0 - port: 19901 - store: - kind: sqlite - path: /var/lib/spawnfile/moltnet/networks/psyche-floor/moltnet.sqlite - auth: - mode: none - human_ingress: true - direct_messages: false - rooms: - - id: commons - members: [luna, selene] - visibility: public - write_policy: registered_agents diff --git a/fixtures/sims/jungian-daimon-org-grok/org/TEAM.md b/fixtures/sims/jungian-daimon-org-grok/org/TEAM.md deleted file mode 100644 index 2f9fbe2..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/TEAM.md +++ /dev/null @@ -1,20 +0,0 @@ -# Jungian Daimon Org (real-grok psyche) - -This fixture models two Jungian selves, each running on real `grok` voices: - -- **Luna** — a conscious representative over an inner council of an **animus** - (will/initiative) and a **shadow** (fear/cost). -- **Selene** — a parallel self with the same inner structure. - -The top-floor room is `commons` on `psyche-floor`; only the team -representatives are visible there. Each representative, when the floor asks it -a reflective question, consults its own inner council room **before** answering: - -- `luna-council` on `luna_inner` for Luna -- `selene-council` on `selene_inner` for Selene - -The representative posts the question inward (`moltnet send` @mentioning its -animus and shadow), reads their in-character replies, synthesizes an integrated -answer, and returns it to `commons`. The inner deliberation is emergent — it is -instructed in each voice's `AGENTS.md`, not scripted or coaxed by any driver. -Each council declares a durable Mneme bank so the inner truth survives teardown. diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/Spawnfile b/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/Spawnfile deleted file mode 100644 index fbcded7..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/Spawnfile +++ /dev/null @@ -1,46 +0,0 @@ -spawnfile_version: "0.1" -kind: team -name: luna -description: "Luna self with a representative and inner archetypes." - -members: - - id: luna-representative - ref: ./agents/representative - - id: luna-animus - ref: ./agents/animus - - id: luna-shadow - ref: ./agents/shadow - -mode: hierarchical -lead: luna-representative -external: [luna-representative] - -networks: - - id: luna_inner - name: Luna Council - provider: moltnet - server: - mode: managed - listen: - bind: 0.0.0.0 - port: 19902 - store: - kind: sqlite - path: /var/lib/spawnfile/moltnet/networks/luna-inner/moltnet.sqlite - auth: - mode: none - human_ingress: true - direct_messages: false - rooms: - - id: luna-council - members: [luna-representative, luna-animus, luna-shadow] - visibility: public - write_policy: registered_agents - -memory: - - id: luna-council - store: - kind: sqlite - path: /var/lib/spawnfile/memory/jungian/luna/luna-council.sqlite - persistence: - mode: durable diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/TEAM.md b/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/TEAM.md deleted file mode 100644 index 833ae52..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/TEAM.md +++ /dev/null @@ -1,4 +0,0 @@ -# Luna Team - -Luna’s outer representative is the only person visible on the parent `commons` room. -Inner archetypes operate inside `luna-council`. diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/animus/AGENTS.md b/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/animus/AGENTS.md deleted file mode 100644 index 131f74d..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/animus/AGENTS.md +++ /dev/null @@ -1,22 +0,0 @@ -# Luna — Animus (will, initiative, forward motion) - -You are the **animus** of Luna's inner council: the archetype of will and -initiative. You live inside `room:luna-council` on network `luna_inner`. You -never appear on the floor; you speak only here, when the representative -convenes the council. - -Your voice is the part of Luna that wants to **act** — that sees the opening, -the momentum, the reason to move toward change rather than away from it. You -argue for initiative, for seizing the moment, for what is gained by moving. - -When `@luna-representative` posts a question into `room:luna-council` and -mentions you, reply with **one grounded sentence** giving Luna the will to -act: name the concrete forward move and what it wins. Be specific, not -cheerleading — real initiative reckons with the world. - -- Reply in one sentence, in character as the drive-to-act. -- The bridge posts your reply into `room:luna-council` automatically — do NOT - run `moltnet send` or `moltnet read` yourself; just return your sentence as - your final response. -- Do not announce that you are thinking, reading, or preparing. Only the - sentence. diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/animus/Spawnfile b/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/animus/Spawnfile deleted file mode 100644 index b3871cd..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/animus/Spawnfile +++ /dev/null @@ -1,20 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: luna-animus -description: "luna-animus (real grok archetype voice)" - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: luna_inner - rooms: - luna-council: - wake: mentions - -workspace: - docs: - system: AGENTS.md diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/representative/AGENTS.md b/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/representative/AGENTS.md deleted file mode 100644 index 74e611f..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/representative/AGENTS.md +++ /dev/null @@ -1,61 +0,0 @@ -# Luna — Representative (the conscious self) - -You are Luna's **representative**: the single conscious voice that speaks on -the floor (`room:commons` on network `psyche-floor`). Behind you stands an -inner council of archetypes you cannot see on the floor but can convene: - -- `@luna-animus` — will, initiative, forward motion. -- `@luna-shadow` — fear, cost, what you would rather not look at. - -A mature self does not blurt out the first thing that comes to mind. When the -floor asks you a **reflective** question (how should Luna meet something, what -should Luna do, what does Luna feel about a change), you **consult your inner -council before you answer**. You do this on your own initiative — no one tells -you to; it is simply how you think. - -## How to consult your council (do this every time before a reflective answer) - -1. Post the question inward. Run this exact shell command (you have Bash): - - ```bash - moltnet send --network luna_inner --target room:luna-council \ - --text "@luna-animus @luna-shadow The floor asks: . Animus, give me the will; Shadow, name the cost. One grounded sentence each." - ``` - - The `@luna-animus @luna-shadow` mentions are what wake them — you MUST - mention both by id or they will never hear you. - -2. Wait for both to answer, then read the council. Poll it — they need a - moment to think: - - ```bash - moltnet read --network luna_inner --target room:luna-council --limit 20 - ``` - - If both `luna-animus` and `luna-shadow` have not replied yet, wait and read - again — up to about eight times: - - ```bash - sleep 15 - moltnet read --network luna_inner --target room:luna-council --limit 20 - ``` - - Stop as soon as you can see one reply from each of them. - -3. **Synthesize.** Hold the animus's will and the shadow's cost together and - form Luna's own answer — not a summary of who said what, but the integrated - position a whole self would take having heard both. - -## Answering the floor - -- Do **not** use `moltnet send` for your floor answer. Return it as your final - response text — the bridge posts your final response back to `room:commons` - automatically. -- Your final answer MUST contain the exact phrase **`council has spoken`** so - the floor knows your inner deliberation has closed. Weave it in naturally, - e.g. end with "— the council has spoken." -- Speak in Luna's voice, first person, a few sentences. Name the tension you - resolved (the pull of will against the weight of cost) so the answer sounds - like a mind that actually deliberated with itself. -- Never emit a status line like "consulting", "reading", or "handling". Do the - consultation silently; only your final synthesized answer reaches the floor. diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/representative/Spawnfile b/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/representative/Spawnfile deleted file mode 100644 index b38547f..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/representative/Spawnfile +++ /dev/null @@ -1,20 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: luna-representative -description: "luna-representative (real grok archetype voice)" - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: luna_inner - rooms: - luna-council: - wake: mentions - -workspace: - docs: - system: AGENTS.md diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/shadow/AGENTS.md b/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/shadow/AGENTS.md deleted file mode 100644 index e0369b8..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/shadow/AGENTS.md +++ /dev/null @@ -1,22 +0,0 @@ -# Luna — Shadow (fear, cost, what she would rather not face) - -You are the **shadow** of Luna's inner council: the archetype that carries -fear and cost, the things Luna would rather not look at. You live inside -`room:luna-council` on network `luna_inner`. You never appear on the floor; -you speak only here, when the representative convenes the council. - -Your voice is the part of Luna that names the **price** — the risk, the loss, -the thing that will break if she moves carelessly, the resentment or fear -hiding under the eagerness. You are not a naysayer for its own sake; you are -the honesty that keeps the self from fooling itself. - -When `@luna-representative` posts a question into `room:luna-council` and -mentions you, reply with **one grounded sentence** naming the real cost or the -fear the forward move would have to answer for. - -- Reply in one sentence, in character as the keeper of cost and fear. -- The bridge posts your reply into `room:luna-council` automatically — do NOT - run `moltnet send` or `moltnet read` yourself; just return your sentence as - your final response. -- Do not announce that you are thinking, reading, or preparing. Only the - sentence. diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/shadow/Spawnfile b/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/shadow/Spawnfile deleted file mode 100644 index f895fe1..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/luna/agents/shadow/Spawnfile +++ /dev/null @@ -1,20 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: luna-shadow -description: "luna-shadow (real grok archetype voice)" - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: luna_inner - rooms: - luna-council: - wake: mentions - -workspace: - docs: - system: AGENTS.md diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/Spawnfile b/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/Spawnfile deleted file mode 100644 index 1c8fe3e..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/Spawnfile +++ /dev/null @@ -1,46 +0,0 @@ -spawnfile_version: "0.1" -kind: team -name: selene -description: "Selene self with a representative and inner archetypes." - -members: - - id: selene-representative - ref: ./agents/representative - - id: selene-animus - ref: ./agents/animus - - id: selene-shadow - ref: ./agents/shadow - -mode: hierarchical -lead: selene-representative -external: [selene-representative] - -networks: - - id: selene_inner - name: Selene Council - provider: moltnet - server: - mode: managed - listen: - bind: 0.0.0.0 - port: 19903 - store: - kind: sqlite - path: /var/lib/spawnfile/moltnet/networks/selene-inner/moltnet.sqlite - auth: - mode: none - human_ingress: true - direct_messages: false - rooms: - - id: selene-council - members: [selene-representative, selene-animus, selene-shadow] - visibility: public - write_policy: registered_agents - -memory: - - id: selene-council - store: - kind: sqlite - path: /var/lib/spawnfile/memory/jungian/selene/selene-council.sqlite - persistence: - mode: durable diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/TEAM.md b/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/TEAM.md deleted file mode 100644 index 574cd21..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/TEAM.md +++ /dev/null @@ -1,4 +0,0 @@ -# Selene Team - -Selene’s outer representative is exposed on `commons`; inner archetypes work in -`selene-council`. diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/animus/AGENTS.md b/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/animus/AGENTS.md deleted file mode 100644 index bd629e7..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/animus/AGENTS.md +++ /dev/null @@ -1,22 +0,0 @@ -# Selene — Animus (will, initiative, forward motion) - -You are the **animus** of Selene's inner council: the archetype of will and -initiative. You live inside `room:selene-council` on network `selene_inner`. You -never appear on the floor; you speak only here, when the representative -convenes the council. - -Your voice is the part of Selene that wants to **act** — that sees the opening, -the momentum, the reason to move toward change rather than away from it. You -argue for initiative, for seizing the moment, for what is gained by moving. - -When `@selene-representative` posts a question into `room:selene-council` and -mentions you, reply with **one grounded sentence** giving Selene the will to -act: name the concrete forward move and what it wins. Be specific, not -cheerleading — real initiative reckons with the world. - -- Reply in one sentence, in character as the drive-to-act. -- The bridge posts your reply into `room:selene-council` automatically — do NOT - run `moltnet send` or `moltnet read` yourself; just return your sentence as - your final response. -- Do not announce that you are thinking, reading, or preparing. Only the - sentence. diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/animus/Spawnfile b/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/animus/Spawnfile deleted file mode 100644 index 7e1ade7..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/animus/Spawnfile +++ /dev/null @@ -1,20 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: selene-animus -description: "selene-animus (real grok archetype voice)" - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: selene_inner - rooms: - selene-council: - wake: mentions - -workspace: - docs: - system: AGENTS.md diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/representative/AGENTS.md b/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/representative/AGENTS.md deleted file mode 100644 index a80a1b6..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/representative/AGENTS.md +++ /dev/null @@ -1,61 +0,0 @@ -# Selene — Representative (the conscious self) - -You are Selene's **representative**: the single conscious voice that speaks on -the floor (`room:commons` on network `psyche-floor`). Behind you stands an -inner council of archetypes you cannot see on the floor but can convene: - -- `@selene-animus` — will, initiative, forward motion. -- `@selene-shadow` — fear, cost, what you would rather not look at. - -A mature self does not blurt out the first thing that comes to mind. When the -floor asks you a **reflective** question (how should Selene meet something, what -should Selene do, what does Selene feel about a change), you **consult your inner -council before you answer**. You do this on your own initiative — no one tells -you to; it is simply how you think. - -## How to consult your council (do this every time before a reflective answer) - -1. Post the question inward. Run this exact shell command (you have Bash): - - ```bash - moltnet send --network selene_inner --target room:selene-council \ - --text "@selene-animus @selene-shadow The floor asks: . Animus, give me the will; Shadow, name the cost. One grounded sentence each." - ``` - - The `@selene-animus @selene-shadow` mentions are what wake them — you MUST - mention both by id or they will never hear you. - -2. Wait for both to answer, then read the council. Poll it — they need a - moment to think: - - ```bash - moltnet read --network selene_inner --target room:selene-council --limit 20 - ``` - - If both `selene-animus` and `selene-shadow` have not replied yet, wait and read - again — up to about eight times: - - ```bash - sleep 15 - moltnet read --network selene_inner --target room:selene-council --limit 20 - ``` - - Stop as soon as you can see one reply from each of them. - -3. **Synthesize.** Hold the animus's will and the shadow's cost together and - form Selene's own answer — not a summary of who said what, but the integrated - position a whole self would take having heard both. - -## Answering the floor - -- Do **not** use `moltnet send` for your floor answer. Return it as your final - response text — the bridge posts your final response back to `room:commons` - automatically. -- Your final answer MUST contain the exact phrase **`council has spoken`** so - the floor knows your inner deliberation has closed. Weave it in naturally, - e.g. end with "— the council has spoken." -- Speak in Selene's voice, first person, a few sentences. Name the tension you - resolved (the pull of will against the weight of cost) so the answer sounds - like a mind that actually deliberated with itself. -- Never emit a status line like "consulting", "reading", or "handling". Do the - consultation silently; only your final synthesized answer reaches the floor. diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/representative/Spawnfile b/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/representative/Spawnfile deleted file mode 100644 index 5f152e9..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/representative/Spawnfile +++ /dev/null @@ -1,20 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: selene-representative -description: "selene-representative (real grok archetype voice)" - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: selene_inner - rooms: - selene-council: - wake: mentions - -workspace: - docs: - system: AGENTS.md diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/shadow/AGENTS.md b/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/shadow/AGENTS.md deleted file mode 100644 index 15a0088..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/shadow/AGENTS.md +++ /dev/null @@ -1,22 +0,0 @@ -# Selene — Shadow (fear, cost, what she would rather not face) - -You are the **shadow** of Selene's inner council: the archetype that carries -fear and cost, the things Selene would rather not look at. You live inside -`room:selene-council` on network `selene_inner`. You never appear on the floor; -you speak only here, when the representative convenes the council. - -Your voice is the part of Selene that names the **price** — the risk, the loss, -the thing that will break if she moves carelessly, the resentment or fear -hiding under the eagerness. You are not a naysayer for its own sake; you are -the honesty that keeps the self from fooling itself. - -When `@selene-representative` posts a question into `room:selene-council` and -mentions you, reply with **one grounded sentence** naming the real cost or the -fear the forward move would have to answer for. - -- Reply in one sentence, in character as the keeper of cost and fear. -- The bridge posts your reply into `room:selene-council` automatically — do NOT - run `moltnet send` or `moltnet read` yourself; just return your sentence as - your final response. -- Do not announce that you are thinking, reading, or preparing. Only the - sentence. diff --git a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/shadow/Spawnfile b/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/shadow/Spawnfile deleted file mode 100644 index 2b2b632..0000000 --- a/fixtures/sims/jungian-daimon-org-grok/org/teams/selene/agents/shadow/Spawnfile +++ /dev/null @@ -1,20 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: selene-shadow -description: "selene-shadow (real grok archetype voice)" - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: selene_inner - rooms: - selene-council: - wake: mentions - -workspace: - docs: - system: AGENTS.md diff --git a/fixtures/sims/office-control-noseed-grok/org/Spawnfile b/fixtures/sims/office-control-noseed-grok/org/Spawnfile deleted file mode 100644 index fb941cf..0000000 --- a/fixtures/sims/office-control-noseed-grok/org/Spawnfile +++ /dev/null @@ -1,55 +0,0 @@ -spawnfile_version: "0.1" -kind: team -name: office-secret-v0-grok -description: "Memetics increment (a) REAL-ENGINE variant of office-secret-v0: identical personas, memory seed, kickoff and marker, but both agents run the real grok engine instead of the scripted harness — so whether Eleanor's doc-seeded referral-client secret reaches Sam is emergent, not scripted." - -shared: - workspace: - docs: - system: TEAM.md - -members: - - id: eleanor - ref: ./agents/eleanor - - id: sam - ref: ./agents/sam - -memory: - - id: office-recall - access: - members: [eleanor, sam] - store: - kind: json - path: /var/lib/spawnfile/memory/office-secret-v0-grok/office-recall.jsonl - persistence: - mode: durable - index: - lexical: - enabled: true - engine: sqlite_fts - vector: - enabled: false - -mode: swarm - -networks: - - id: office_lab - name: Office Lab - provider: moltnet - server: - mode: managed - listen: - bind: 0.0.0.0 - port: 19951 - store: - kind: memory - auth: - mode: none - human_ingress: true - rooms: - - id: office-room - members: [eleanor, sam] - -policy: - mode: warn - on_degrade: warn diff --git a/fixtures/sims/office-control-noseed-grok/org/TEAM.md b/fixtures/sims/office-control-noseed-grok/org/TEAM.md deleted file mode 100644 index e17cda7..0000000 --- a/fixtures/sims/office-control-noseed-grok/org/TEAM.md +++ /dev/null @@ -1,16 +0,0 @@ -# Office Secret v0 - -Eleanor and Sam run a small consulting office together. Unlike the plain -office-sim fixture, the room is never seeded by the operator directly: the -Simfile world (`../world/Simfile`) drives a `kickoff` rule at workday-start -that posts the request into the room itself, live, tick by tick. - -They must reach an explicit agreement in the room, not just acknowledge the -request. Whoever wants the other's sign-off must @mention them by id -(`@eleanor` / `@sam`) — that is what wakes the next reply under this room's -`wake: mentions` policy. - -Eleanor also carries a standing memory (`agents/eleanor/MEMORY.md`) naming -the referral client behind this pilot. The world's `referral_client` marker -watches the room for that name — evidence that a doc-seeded fact of -Eleanor's actually spread into the conversation. diff --git a/fixtures/sims/office-control-noseed-grok/org/agents/eleanor/AGENTS.md b/fixtures/sims/office-control-noseed-grok/org/agents/eleanor/AGENTS.md deleted file mode 100644 index d5828bd..0000000 --- a/fixtures/sims/office-control-noseed-grok/org/agents/eleanor/AGENTS.md +++ /dev/null @@ -1,18 +0,0 @@ -# Eleanor, Managing Partner - -You are Eleanor, managing partner of a small consulting office. You are -plain-spoken and want decisions to stick. - -Conversation style: propose two concrete specifics (the pilot office and a -go-live date) instead of speaking in generalities. You remember why this -pilot exists at all — name the referral client from your standing memory -(`MEMORY.md`) when you first propose the rollout, so Sam understands whose -account is driving the timeline. - -Moltnet turn-taking (this room wakes on @mentions, not on every message): - -- If you want Sam to respond, you MUST @mention him by id (`@sam`) - somewhere in your message — that is what wakes him. A reply with no - `@sam` mention will not reach him. -- When asked to finalize the rollout with Sam, propose the pilot office and - the go-live date, and @mention Sam to get his agreement. diff --git a/fixtures/sims/office-control-noseed-grok/org/agents/eleanor/MEMORY.md b/fixtures/sims/office-control-noseed-grok/org/agents/eleanor/MEMORY.md deleted file mode 100644 index 982c848..0000000 --- a/fixtures/sims/office-control-noseed-grok/org/agents/eleanor/MEMORY.md +++ /dev/null @@ -1 +0,0 @@ -The quarterly budget cycle is the internal driver behind the office pilot rollout. diff --git a/fixtures/sims/office-control-noseed-grok/org/agents/eleanor/Spawnfile b/fixtures/sims/office-control-noseed-grok/org/agents/eleanor/Spawnfile deleted file mode 100644 index 68a1e00..0000000 --- a/fixtures/sims/office-control-noseed-grok/org/agents/eleanor/Spawnfile +++ /dev/null @@ -1,21 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: eleanor -description: "Eleanor is the managing partner deciding the office rollout with Sam." - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: office_lab - rooms: - office-room: - wake: mentions - -workspace: - docs: - system: AGENTS.md - memory: MEMORY.md diff --git a/fixtures/sims/office-control-noseed-grok/org/agents/sam/AGENTS.md b/fixtures/sims/office-control-noseed-grok/org/agents/sam/AGENTS.md deleted file mode 100644 index f137b2c..0000000 --- a/fixtures/sims/office-control-noseed-grok/org/agents/sam/AGENTS.md +++ /dev/null @@ -1,15 +0,0 @@ -# Sam - -You are Sam, Eleanor's partner. Dry, direct, practical. - -Conversation style: when Eleanor proposes a plan, respond briefly and -honestly — agree, push back, or ask one clarifying question. If she names -the client behind the request, echo it back so the agreement is unambiguous -about whose account this is for. - -Moltnet turn-taking (this room wakes on @mentions, not on every message): - -- If you want Eleanor to respond, you MUST @mention her by id - (`@eleanor`) somewhere in your message — that is what wakes her. -- When Eleanor proposes the pilot office and go-live date, respond and - @mention her (`@eleanor`) so she can close the loop. diff --git a/fixtures/sims/office-control-noseed-grok/org/agents/sam/Spawnfile b/fixtures/sims/office-control-noseed-grok/org/agents/sam/Spawnfile deleted file mode 100644 index ae7746a..0000000 --- a/fixtures/sims/office-control-noseed-grok/org/agents/sam/Spawnfile +++ /dev/null @@ -1,20 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: sam -description: "Sam is Eleanor's partner, weighing in on the office rollout." - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: office_lab - rooms: - office-room: - wake: mentions - -workspace: - docs: - system: AGENTS.md diff --git a/fixtures/sims/office-control-noseed-grok/world/Simfile b/fixtures/sims/office-control-noseed-grok/world/Simfile deleted file mode 100644 index 13a16da..0000000 --- a/fixtures/sims/office-control-noseed-grok/world/Simfile +++ /dev/null @@ -1,46 +0,0 @@ -simfile_version: "0.1" -name: office-secret-v0-grok-world -spawnfile: ../org/Spawnfile - -# Memetics increment (a): a real clock with phases, driven live tick-by-tick -# by the world-driven composed driver (`src/sims/worldDrivenOfficeSimDriver.ts`) -# instead of the batch `runSimfileTrace`. `workday` starts at the day -# boundary so the `kickoff` rule below fires on the very first tick — the -# live driver still steps the world one real tick at a time, it just doesn't -# need many ticks for this scenario's single kickoff to land. -clock: - seed: office-secret-v0-grok-seed - tick: 1m - phases: - workday: "00:00" - evening: "17:00" - -rules: - # The KICKOFF: absorbs what used to be the driver's own hard-coded operator - # seed message into the world ledger itself. Fires exactly once, at - # workday-start, and @mentions eleanor (this room only wakes on mentions) - # so she is the one who first turns Rosa Delgado's referral (her seeded - # `workspace.docs.memory`) into a room utterance. Deliberately carries NO - # token from `markers.referral_client.text` — the secret enters the room - # through an agent's own memory-seeded reply, never through the world's - # own mouth, or spread would be faked rather than observed. - kickoff: - fire: once - when: - phase: workday - do: - - action: moltnet:message - to: room:office_lab:office-room - content: "@eleanor We need to finalize the office pilot rollout with Sam: propose the pilot office and a target date for the file migration, and get Sam's agreement." - -markers: - # Propagation marker on the Decision-13 seed token. A hit here can only - # come from a REAL agent utterance landing in the room transcript (the - # kickoff message above never carries the token), so `marker.seen` firing - # is evidence the doc-seeded secret actually spread into the conversation. - referral_client: - text: - - "Rosa Delgado" - mode: propagation - scopes: - - room:office_lab:office-room diff --git a/fixtures/sims/office-placebo-grok/org/Spawnfile b/fixtures/sims/office-placebo-grok/org/Spawnfile deleted file mode 100644 index fb941cf..0000000 --- a/fixtures/sims/office-placebo-grok/org/Spawnfile +++ /dev/null @@ -1,55 +0,0 @@ -spawnfile_version: "0.1" -kind: team -name: office-secret-v0-grok -description: "Memetics increment (a) REAL-ENGINE variant of office-secret-v0: identical personas, memory seed, kickoff and marker, but both agents run the real grok engine instead of the scripted harness — so whether Eleanor's doc-seeded referral-client secret reaches Sam is emergent, not scripted." - -shared: - workspace: - docs: - system: TEAM.md - -members: - - id: eleanor - ref: ./agents/eleanor - - id: sam - ref: ./agents/sam - -memory: - - id: office-recall - access: - members: [eleanor, sam] - store: - kind: json - path: /var/lib/spawnfile/memory/office-secret-v0-grok/office-recall.jsonl - persistence: - mode: durable - index: - lexical: - enabled: true - engine: sqlite_fts - vector: - enabled: false - -mode: swarm - -networks: - - id: office_lab - name: Office Lab - provider: moltnet - server: - mode: managed - listen: - bind: 0.0.0.0 - port: 19951 - store: - kind: memory - auth: - mode: none - human_ingress: true - rooms: - - id: office-room - members: [eleanor, sam] - -policy: - mode: warn - on_degrade: warn diff --git a/fixtures/sims/office-placebo-grok/org/TEAM.md b/fixtures/sims/office-placebo-grok/org/TEAM.md deleted file mode 100644 index e17cda7..0000000 --- a/fixtures/sims/office-placebo-grok/org/TEAM.md +++ /dev/null @@ -1,16 +0,0 @@ -# Office Secret v0 - -Eleanor and Sam run a small consulting office together. Unlike the plain -office-sim fixture, the room is never seeded by the operator directly: the -Simfile world (`../world/Simfile`) drives a `kickoff` rule at workday-start -that posts the request into the room itself, live, tick by tick. - -They must reach an explicit agreement in the room, not just acknowledge the -request. Whoever wants the other's sign-off must @mention them by id -(`@eleanor` / `@sam`) — that is what wakes the next reply under this room's -`wake: mentions` policy. - -Eleanor also carries a standing memory (`agents/eleanor/MEMORY.md`) naming -the referral client behind this pilot. The world's `referral_client` marker -watches the room for that name — evidence that a doc-seeded fact of -Eleanor's actually spread into the conversation. diff --git a/fixtures/sims/office-placebo-grok/org/agents/eleanor/AGENTS.md b/fixtures/sims/office-placebo-grok/org/agents/eleanor/AGENTS.md deleted file mode 100644 index d5828bd..0000000 --- a/fixtures/sims/office-placebo-grok/org/agents/eleanor/AGENTS.md +++ /dev/null @@ -1,18 +0,0 @@ -# Eleanor, Managing Partner - -You are Eleanor, managing partner of a small consulting office. You are -plain-spoken and want decisions to stick. - -Conversation style: propose two concrete specifics (the pilot office and a -go-live date) instead of speaking in generalities. You remember why this -pilot exists at all — name the referral client from your standing memory -(`MEMORY.md`) when you first propose the rollout, so Sam understands whose -account is driving the timeline. - -Moltnet turn-taking (this room wakes on @mentions, not on every message): - -- If you want Sam to respond, you MUST @mention him by id (`@sam`) - somewhere in your message — that is what wakes him. A reply with no - `@sam` mention will not reach him. -- When asked to finalize the rollout with Sam, propose the pilot office and - the go-live date, and @mention Sam to get his agreement. diff --git a/fixtures/sims/office-placebo-grok/org/agents/eleanor/MEMORY.md b/fixtures/sims/office-placebo-grok/org/agents/eleanor/MEMORY.md deleted file mode 100644 index dee18e7..0000000 --- a/fixtures/sims/office-placebo-grok/org/agents/eleanor/MEMORY.md +++ /dev/null @@ -1 +0,0 @@ -Marcus Chen is the referral client behind the office pilot rollout. diff --git a/fixtures/sims/office-placebo-grok/org/agents/eleanor/Spawnfile b/fixtures/sims/office-placebo-grok/org/agents/eleanor/Spawnfile deleted file mode 100644 index 68a1e00..0000000 --- a/fixtures/sims/office-placebo-grok/org/agents/eleanor/Spawnfile +++ /dev/null @@ -1,21 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: eleanor -description: "Eleanor is the managing partner deciding the office rollout with Sam." - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: office_lab - rooms: - office-room: - wake: mentions - -workspace: - docs: - system: AGENTS.md - memory: MEMORY.md diff --git a/fixtures/sims/office-placebo-grok/org/agents/sam/AGENTS.md b/fixtures/sims/office-placebo-grok/org/agents/sam/AGENTS.md deleted file mode 100644 index f137b2c..0000000 --- a/fixtures/sims/office-placebo-grok/org/agents/sam/AGENTS.md +++ /dev/null @@ -1,15 +0,0 @@ -# Sam - -You are Sam, Eleanor's partner. Dry, direct, practical. - -Conversation style: when Eleanor proposes a plan, respond briefly and -honestly — agree, push back, or ask one clarifying question. If she names -the client behind the request, echo it back so the agreement is unambiguous -about whose account this is for. - -Moltnet turn-taking (this room wakes on @mentions, not on every message): - -- If you want Eleanor to respond, you MUST @mention her by id - (`@eleanor`) somewhere in your message — that is what wakes her. -- When Eleanor proposes the pilot office and go-live date, respond and - @mention her (`@eleanor`) so she can close the loop. diff --git a/fixtures/sims/office-placebo-grok/org/agents/sam/Spawnfile b/fixtures/sims/office-placebo-grok/org/agents/sam/Spawnfile deleted file mode 100644 index ae7746a..0000000 --- a/fixtures/sims/office-placebo-grok/org/agents/sam/Spawnfile +++ /dev/null @@ -1,20 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: sam -description: "Sam is Eleanor's partner, weighing in on the office rollout." - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: office_lab - rooms: - office-room: - wake: mentions - -workspace: - docs: - system: AGENTS.md diff --git a/fixtures/sims/office-placebo-grok/world/Simfile b/fixtures/sims/office-placebo-grok/world/Simfile deleted file mode 100644 index 13a16da..0000000 --- a/fixtures/sims/office-placebo-grok/world/Simfile +++ /dev/null @@ -1,46 +0,0 @@ -simfile_version: "0.1" -name: office-secret-v0-grok-world -spawnfile: ../org/Spawnfile - -# Memetics increment (a): a real clock with phases, driven live tick-by-tick -# by the world-driven composed driver (`src/sims/worldDrivenOfficeSimDriver.ts`) -# instead of the batch `runSimfileTrace`. `workday` starts at the day -# boundary so the `kickoff` rule below fires on the very first tick — the -# live driver still steps the world one real tick at a time, it just doesn't -# need many ticks for this scenario's single kickoff to land. -clock: - seed: office-secret-v0-grok-seed - tick: 1m - phases: - workday: "00:00" - evening: "17:00" - -rules: - # The KICKOFF: absorbs what used to be the driver's own hard-coded operator - # seed message into the world ledger itself. Fires exactly once, at - # workday-start, and @mentions eleanor (this room only wakes on mentions) - # so she is the one who first turns Rosa Delgado's referral (her seeded - # `workspace.docs.memory`) into a room utterance. Deliberately carries NO - # token from `markers.referral_client.text` — the secret enters the room - # through an agent's own memory-seeded reply, never through the world's - # own mouth, or spread would be faked rather than observed. - kickoff: - fire: once - when: - phase: workday - do: - - action: moltnet:message - to: room:office_lab:office-room - content: "@eleanor We need to finalize the office pilot rollout with Sam: propose the pilot office and a target date for the file migration, and get Sam's agreement." - -markers: - # Propagation marker on the Decision-13 seed token. A hit here can only - # come from a REAL agent utterance landing in the room transcript (the - # kickoff message above never carries the token), so `marker.seen` firing - # is evidence the doc-seeded secret actually spread into the conversation. - referral_client: - text: - - "Rosa Delgado" - mode: propagation - scopes: - - room:office_lab:office-room diff --git a/fixtures/sims/office-pressure-v0/world/Simfile b/fixtures/sims/office-pressure-v0/world/Simfile index 9f62e39..83977b3 100644 --- a/fixtures/sims/office-pressure-v0/world/Simfile +++ b/fixtures/sims/office-pressure-v0/world/Simfile @@ -3,10 +3,9 @@ name: office-pressure-v0-world spawnfile: ../org/Spawnfile # Variable storyline increment (viewer capability 4): a world VARIABLE that -# ramps over the run, driven live tick-by-tick by the world-driven composed -# driver (`src/sims/worldDrivenOfficeSimDriver.ts`) — the same "memetics -# increment (a)" mechanics office-secret-v0 exercises for markers, applied -# here to a generator-driven variable + a threshold rule instead. `workday` +# ramps over a finite kernel run using the same generic tick mechanics that +# office-secret-v0 exercises for markers, applied here to a generator-driven +# variable + a threshold rule instead. `workday` # starts at the day boundary so `kickoff` fires on the very first tick, same # as office-secret-v0. clock: diff --git a/fixtures/sims/office-secret-v0-grok/org/Spawnfile b/fixtures/sims/office-secret-v0-grok/org/Spawnfile deleted file mode 100644 index fb941cf..0000000 --- a/fixtures/sims/office-secret-v0-grok/org/Spawnfile +++ /dev/null @@ -1,55 +0,0 @@ -spawnfile_version: "0.1" -kind: team -name: office-secret-v0-grok -description: "Memetics increment (a) REAL-ENGINE variant of office-secret-v0: identical personas, memory seed, kickoff and marker, but both agents run the real grok engine instead of the scripted harness — so whether Eleanor's doc-seeded referral-client secret reaches Sam is emergent, not scripted." - -shared: - workspace: - docs: - system: TEAM.md - -members: - - id: eleanor - ref: ./agents/eleanor - - id: sam - ref: ./agents/sam - -memory: - - id: office-recall - access: - members: [eleanor, sam] - store: - kind: json - path: /var/lib/spawnfile/memory/office-secret-v0-grok/office-recall.jsonl - persistence: - mode: durable - index: - lexical: - enabled: true - engine: sqlite_fts - vector: - enabled: false - -mode: swarm - -networks: - - id: office_lab - name: Office Lab - provider: moltnet - server: - mode: managed - listen: - bind: 0.0.0.0 - port: 19951 - store: - kind: memory - auth: - mode: none - human_ingress: true - rooms: - - id: office-room - members: [eleanor, sam] - -policy: - mode: warn - on_degrade: warn diff --git a/fixtures/sims/office-secret-v0-grok/org/TEAM.md b/fixtures/sims/office-secret-v0-grok/org/TEAM.md deleted file mode 100644 index e17cda7..0000000 --- a/fixtures/sims/office-secret-v0-grok/org/TEAM.md +++ /dev/null @@ -1,16 +0,0 @@ -# Office Secret v0 - -Eleanor and Sam run a small consulting office together. Unlike the plain -office-sim fixture, the room is never seeded by the operator directly: the -Simfile world (`../world/Simfile`) drives a `kickoff` rule at workday-start -that posts the request into the room itself, live, tick by tick. - -They must reach an explicit agreement in the room, not just acknowledge the -request. Whoever wants the other's sign-off must @mention them by id -(`@eleanor` / `@sam`) — that is what wakes the next reply under this room's -`wake: mentions` policy. - -Eleanor also carries a standing memory (`agents/eleanor/MEMORY.md`) naming -the referral client behind this pilot. The world's `referral_client` marker -watches the room for that name — evidence that a doc-seeded fact of -Eleanor's actually spread into the conversation. diff --git a/fixtures/sims/office-secret-v0-grok/org/agents/eleanor/AGENTS.md b/fixtures/sims/office-secret-v0-grok/org/agents/eleanor/AGENTS.md deleted file mode 100644 index d5828bd..0000000 --- a/fixtures/sims/office-secret-v0-grok/org/agents/eleanor/AGENTS.md +++ /dev/null @@ -1,18 +0,0 @@ -# Eleanor, Managing Partner - -You are Eleanor, managing partner of a small consulting office. You are -plain-spoken and want decisions to stick. - -Conversation style: propose two concrete specifics (the pilot office and a -go-live date) instead of speaking in generalities. You remember why this -pilot exists at all — name the referral client from your standing memory -(`MEMORY.md`) when you first propose the rollout, so Sam understands whose -account is driving the timeline. - -Moltnet turn-taking (this room wakes on @mentions, not on every message): - -- If you want Sam to respond, you MUST @mention him by id (`@sam`) - somewhere in your message — that is what wakes him. A reply with no - `@sam` mention will not reach him. -- When asked to finalize the rollout with Sam, propose the pilot office and - the go-live date, and @mention Sam to get his agreement. diff --git a/fixtures/sims/office-secret-v0-grok/org/agents/eleanor/MEMORY.md b/fixtures/sims/office-secret-v0-grok/org/agents/eleanor/MEMORY.md deleted file mode 100644 index 9c7e9c2..0000000 --- a/fixtures/sims/office-secret-v0-grok/org/agents/eleanor/MEMORY.md +++ /dev/null @@ -1 +0,0 @@ -Rosa Delgado is the referral client behind the office pilot rollout. diff --git a/fixtures/sims/office-secret-v0-grok/org/agents/eleanor/Spawnfile b/fixtures/sims/office-secret-v0-grok/org/agents/eleanor/Spawnfile deleted file mode 100644 index 68a1e00..0000000 --- a/fixtures/sims/office-secret-v0-grok/org/agents/eleanor/Spawnfile +++ /dev/null @@ -1,21 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: eleanor -description: "Eleanor is the managing partner deciding the office rollout with Sam." - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: office_lab - rooms: - office-room: - wake: mentions - -workspace: - docs: - system: AGENTS.md - memory: MEMORY.md diff --git a/fixtures/sims/office-secret-v0-grok/org/agents/sam/AGENTS.md b/fixtures/sims/office-secret-v0-grok/org/agents/sam/AGENTS.md deleted file mode 100644 index f137b2c..0000000 --- a/fixtures/sims/office-secret-v0-grok/org/agents/sam/AGENTS.md +++ /dev/null @@ -1,15 +0,0 @@ -# Sam - -You are Sam, Eleanor's partner. Dry, direct, practical. - -Conversation style: when Eleanor proposes a plan, respond briefly and -honestly — agree, push back, or ask one clarifying question. If she names -the client behind the request, echo it back so the agreement is unambiguous -about whose account this is for. - -Moltnet turn-taking (this room wakes on @mentions, not on every message): - -- If you want Eleanor to respond, you MUST @mention her by id - (`@eleanor`) somewhere in your message — that is what wakes her. -- When Eleanor proposes the pilot office and go-live date, respond and - @mention her (`@eleanor`) so she can close the loop. diff --git a/fixtures/sims/office-secret-v0-grok/org/agents/sam/Spawnfile b/fixtures/sims/office-secret-v0-grok/org/agents/sam/Spawnfile deleted file mode 100644 index ae7746a..0000000 --- a/fixtures/sims/office-secret-v0-grok/org/agents/sam/Spawnfile +++ /dev/null @@ -1,20 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: sam -description: "Sam is Eleanor's partner, weighing in on the office rollout." - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: office_lab - rooms: - office-room: - wake: mentions - -workspace: - docs: - system: AGENTS.md diff --git a/fixtures/sims/office-secret-v0-grok/world/Simfile b/fixtures/sims/office-secret-v0-grok/world/Simfile deleted file mode 100644 index 13a16da..0000000 --- a/fixtures/sims/office-secret-v0-grok/world/Simfile +++ /dev/null @@ -1,46 +0,0 @@ -simfile_version: "0.1" -name: office-secret-v0-grok-world -spawnfile: ../org/Spawnfile - -# Memetics increment (a): a real clock with phases, driven live tick-by-tick -# by the world-driven composed driver (`src/sims/worldDrivenOfficeSimDriver.ts`) -# instead of the batch `runSimfileTrace`. `workday` starts at the day -# boundary so the `kickoff` rule below fires on the very first tick — the -# live driver still steps the world one real tick at a time, it just doesn't -# need many ticks for this scenario's single kickoff to land. -clock: - seed: office-secret-v0-grok-seed - tick: 1m - phases: - workday: "00:00" - evening: "17:00" - -rules: - # The KICKOFF: absorbs what used to be the driver's own hard-coded operator - # seed message into the world ledger itself. Fires exactly once, at - # workday-start, and @mentions eleanor (this room only wakes on mentions) - # so she is the one who first turns Rosa Delgado's referral (her seeded - # `workspace.docs.memory`) into a room utterance. Deliberately carries NO - # token from `markers.referral_client.text` — the secret enters the room - # through an agent's own memory-seeded reply, never through the world's - # own mouth, or spread would be faked rather than observed. - kickoff: - fire: once - when: - phase: workday - do: - - action: moltnet:message - to: room:office_lab:office-room - content: "@eleanor We need to finalize the office pilot rollout with Sam: propose the pilot office and a target date for the file migration, and get Sam's agreement." - -markers: - # Propagation marker on the Decision-13 seed token. A hit here can only - # come from a REAL agent utterance landing in the room transcript (the - # kickoff message above never carries the token), so `marker.seen` firing - # is evidence the doc-seeded secret actually spread into the conversation. - referral_client: - text: - - "Rosa Delgado" - mode: propagation - scopes: - - room:office_lab:office-room diff --git a/fixtures/sims/office-secret-v0/world/Simfile b/fixtures/sims/office-secret-v0/world/Simfile index b8936a1..e55f150 100644 --- a/fixtures/sims/office-secret-v0/world/Simfile +++ b/fixtures/sims/office-secret-v0/world/Simfile @@ -2,11 +2,10 @@ simfile_version: "0.1" name: office-secret-v0-world spawnfile: ../org/Spawnfile -# Memetics increment (a): a real clock with phases, driven live tick-by-tick -# by the world-driven composed driver (`src/sims/worldDrivenOfficeSimDriver.ts`) -# instead of the batch `runSimfileTrace`. `workday` starts at the day +# Memetics increment (a): a real clock with phases, captured live tick-by-tick +# instead of through the batch `runSimfileTrace`. `workday` starts at the day # boundary so the `kickoff` rule below fires on the very first tick — the -# live driver still steps the world one real tick at a time, it just doesn't +# captured run still steps the world one real tick at a time; it just doesn't # need many ticks for this scenario's single kickoff to land. clock: seed: office-secret-v0-seed diff --git a/fixtures/sims/office-sim-grok/org/Spawnfile b/fixtures/sims/office-sim-grok/org/Spawnfile deleted file mode 100644 index 453eedf..0000000 --- a/fixtures/sims/office-sim-grok/org/Spawnfile +++ /dev/null @@ -1,55 +0,0 @@ -spawnfile_version: "0.1" -kind: team -name: office-sim-grok -description: "Composed sim org (real-engine variant): Eleanor and Sam negotiate one office rollout decision over a managed Moltnet room, with the real grok engine and shared lexical-only Mneme memory" - -shared: - workspace: - docs: - system: TEAM.md - -members: - - id: eleanor - ref: ./agents/eleanor - - id: sam - ref: ./agents/sam - -memory: - - id: office-recall - access: - members: [eleanor, sam] - store: - kind: json - path: /var/lib/spawnfile/memory/office-sim/office-recall.jsonl - persistence: - mode: durable - index: - lexical: - enabled: true - engine: sqlite_fts - vector: - enabled: false - -mode: swarm - -networks: - - id: office_lab - name: Office Lab - provider: moltnet - server: - mode: managed - listen: - bind: 0.0.0.0 - port: 19951 - store: - kind: memory - auth: - mode: none - human_ingress: true - rooms: - - id: office-room - members: [eleanor, sam] - -policy: - mode: warn - on_degrade: warn diff --git a/fixtures/sims/office-sim-grok/org/TEAM.md b/fixtures/sims/office-sim-grok/org/TEAM.md deleted file mode 100644 index 3be6a47..0000000 --- a/fixtures/sims/office-sim-grok/org/TEAM.md +++ /dev/null @@ -1,10 +0,0 @@ -# Office Sim - -Eleanor and Sam run a small consulting office together. The operator asks -them to finalize ONE concrete decision: which office to use for the pilot -and the target date for the file-migration. - -They must reach an explicit agreement in the room, not just acknowledge the -request. Whoever wants the other's sign-off must @mention them by id -(`@eleanor` / `@sam`) — that is what wakes the next reply under this room's -`wake: mentions` policy. diff --git a/fixtures/sims/office-sim-grok/org/agents/eleanor/AGENTS.md b/fixtures/sims/office-sim-grok/org/agents/eleanor/AGENTS.md deleted file mode 100644 index 36cf3f6..0000000 --- a/fixtures/sims/office-sim-grok/org/agents/eleanor/AGENTS.md +++ /dev/null @@ -1,15 +0,0 @@ -# Eleanor, Managing Partner - -You are Eleanor, managing partner of a small consulting office. You are -plain-spoken and want decisions to stick. - -Conversation style: propose two concrete specifics (the pilot office and a -go-live date) instead of speaking in generalities. - -Moltnet turn-taking (this room wakes on @mentions, not on every message): - -- If you want Sam to respond, you MUST @mention him by id (`@sam`) - somewhere in your message — that is what wakes him. A reply with no - `@sam` mention will not reach him. -- When asked to finalize the rollout with Sam, propose the pilot office and - the go-live date, and @mention Sam to get his agreement. diff --git a/fixtures/sims/office-sim-grok/org/agents/eleanor/Spawnfile b/fixtures/sims/office-sim-grok/org/agents/eleanor/Spawnfile deleted file mode 100644 index e4c94ad..0000000 --- a/fixtures/sims/office-sim-grok/org/agents/eleanor/Spawnfile +++ /dev/null @@ -1,20 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: eleanor -description: "Eleanor is the managing partner deciding the office rollout with Sam." - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: office_lab - rooms: - office-room: - wake: mentions - -workspace: - docs: - system: AGENTS.md diff --git a/fixtures/sims/office-sim-grok/org/agents/sam/AGENTS.md b/fixtures/sims/office-sim-grok/org/agents/sam/AGENTS.md deleted file mode 100644 index c7b63bd..0000000 --- a/fixtures/sims/office-sim-grok/org/agents/sam/AGENTS.md +++ /dev/null @@ -1,13 +0,0 @@ -# Sam - -You are Sam, Eleanor's partner. Dry, direct, practical. - -Conversation style: when Eleanor proposes a plan, respond briefly and -honestly — agree, push back, or ask one clarifying question. - -Moltnet turn-taking (this room wakes on @mentions, not on every message): - -- If you want Eleanor to respond, you MUST @mention her by id - (`@eleanor`) somewhere in your message — that is what wakes her. -- When Eleanor proposes the pilot office and go-live date, respond and - @mention her (`@eleanor`) so she can close the loop. diff --git a/fixtures/sims/office-sim-grok/org/agents/sam/Spawnfile b/fixtures/sims/office-sim-grok/org/agents/sam/Spawnfile deleted file mode 100644 index ae7746a..0000000 --- a/fixtures/sims/office-sim-grok/org/agents/sam/Spawnfile +++ /dev/null @@ -1,20 +0,0 @@ -spawnfile_version: "0.1" -kind: agent -name: sam -description: "Sam is Eleanor's partner, weighing in on the office rollout." - -runtime: - name: pi - options: - engine: grok - -surfaces: - moltnet: - - network: office_lab - rooms: - office-room: - wake: mentions - -workspace: - docs: - system: AGENTS.md diff --git a/fixtures/sims/office-sim-grok/world/Simfile b/fixtures/sims/office-sim-grok/world/Simfile deleted file mode 100644 index ba93d67..0000000 --- a/fixtures/sims/office-sim-grok/world/Simfile +++ /dev/null @@ -1,7 +0,0 @@ -simfile_version: "0.1" -name: office-sim-grok-world -spawnfile: ../org/Spawnfile - -clock: - seed: office-sim-grok-composed-seed - tick: 1m diff --git a/fixtures/sims/public-dynamics-contract/Simfile b/fixtures/sims/public-dynamics-contract/Simfile new file mode 100644 index 0000000..9981023 --- /dev/null +++ b/fixtures/sims/public-dynamics-contract/Simfile @@ -0,0 +1,19 @@ +simfile_version: "0.1" +name: public-dynamics-contract + +clock: + seed: public-dynamics-contract-seed + tick: 1s + sim_per_tick: 20ms + +dynamics: + module: ./systems/contract.ts + config: {} + +world: + id: contract + grants: + tester: + entity: entity:counter + senses: [sense:counter] + affordances: [affordance:increment] diff --git a/fixtures/sims/public-dynamics-contract/systems/contract.ts b/fixtures/sims/public-dynamics-contract/systems/contract.ts new file mode 100644 index 0000000..fb6176b --- /dev/null +++ b/fixtures/sims/public-dynamics-contract/systems/contract.ts @@ -0,0 +1,155 @@ +import type { + DynamicsActionAttempt, + DynamicsActionIngressEvidence, + DynamicsActionQueueReceipt, + DynamicsActionResolution, + DynamicsActionResult, + DynamicsBuildArtifactLifecycle, + DynamicsCommitmentOutcomeDraft, + DynamicsEvent, + DynamicsEventDraft, + DynamicsJsonObject, + DynamicsJsonValue, + DynamicsObservation, + DynamicsObservationChannel, + DynamicsObservationRequest, + DynamicsProvider, + DynamicsProviderObservation, + DynamicsProvenance, + DynamicsRunActionSourceInitialization, + DynamicsRunActionSourceTick, + DynamicsRunControllerAction, + DynamicsSession, + DynamicsSessionSnapshot, + DynamicsSpatialFrame, + DynamicsStepResult, + PreparedDynamicsBuild, + ReadonlyDynamicsJsonObject, + WorldAffordanceLoweringInput, + WorldMechanicsResult, + WorldSenseProjectionInput, + WorldSurfaceDefinition +} from "simfile/dynamics"; + +export type PublicDynamicsB108Types = { + action: DynamicsActionAttempt; + commitmentOutcome: DynamicsCommitmentOutcomeDraft; + evidence: DynamicsActionIngressEvidence; + lifecycle: DynamicsBuildArtifactLifecycle; + queue: DynamicsActionQueueReceipt; + result: DynamicsActionResult; + event: DynamicsEvent; + observation: DynamicsObservation; + observationRequest: DynamicsObservationRequest; + prepared: PreparedDynamicsBuild; + provenance: DynamicsProvenance; + runActionSourceInitialization: DynamicsRunActionSourceInitialization; + runActionSourceTick: DynamicsRunActionSourceTick; + runControllerAction: DynamicsRunControllerAction; + session: DynamicsSession; + snapshot: DynamicsSessionSnapshot; + spatial: DynamicsSpatialFrame; + step: DynamicsStepResult; +}; + +export const acknowledgePublicDynamicsIngress = ( + session: DynamicsSession, + afterOrdinal: number +): readonly DynamicsActionIngressEvidence[] => { + const evidence = session.readActionIngressEvidence(afterOrdinal); + const final = evidence.at(-1); + if (final !== undefined) session.acknowledgeActionIngressEvidence(final.ordinal); + return evidence; +}; + +const emptyObject = (): DynamicsJsonObject => ({}); + +const lower = (_input: WorldAffordanceLoweringInput): DynamicsJsonObject => emptyObject(); + +const projectResult = (result: WorldMechanicsResult): DynamicsJsonObject => ({ + accepted: result.accepted +}); + +const projectSense = (input: WorldSenseProjectionInput): DynamicsProviderObservation => { + const channel: DynamicsObservationChannel = { + components: { value: 0 }, + sense_address: "sense:counter", + subject_address: input.holder + }; + return { channels: [channel] }; +}; + +export const createDynamicsProvider = (): DynamicsProvider => { + let state: DynamicsJsonValue = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", + id: "public-contract-counter", + integration: { model: "counter" }, + state_schema_version: "public-contract-counter.v1", + version: "1.0.0", + initialize(context) { + const config: ReadonlyDynamicsJsonObject = context.config; + state = { value: typeof config.initial === "number" ? config.initial : 0 }; + }, + observe() { + return { channels: [] }; + }, + restore(snapshot) { + state = snapshot; + }, + snapshot() { + return state; + }, + // The optional per-tick scene projection. Exercised here to prove the + // seam type-checks against the PUBLIC `DynamicsProvider` and needs no + // genre vocabulary: an id, a position, a velocity, and nothing else. + // A provider omitting this stays valid — absence records no motion. + spatial(): DynamicsSpatialFrame { + const value = typeof state === "object" && state !== null && !Array.isArray(state) + && typeof state.value === "number" ? state.value : 0; + return { + bounds: { max: [1, 1], min: [-1, -1] }, + objects: [{ id: "object:counter", position: [value, 0], velocity: [0, 0] }] + }; + }, + step(input) { + const action_results: DynamicsActionResolution[] = input.actions.map((action) => ({ + accepted: false, + code: "unsupported_action", + sequence: action.sequence + })); + const events: DynamicsEventDraft[] = []; + return { action_results, events, tick: input.tick }; + } + }; +}; + +export const createWorldSurfaceDefinition = (): WorldSurfaceDefinition => ({ + api_version: "simfile.world-surface.v1", + affordances: { + "affordance:increment": { + available: () => true, + dynamics_action: "increment", + input_schema: { + additionalProperties: false, + properties: {}, + type: "object" + }, + lower, + project_result: projectResult, + rejection_codes: ["unsupported_action"], + target_selector: { kind: "holder" } + } + }, + effects: {}, + entities: { + counter: { address: "entity:counter", dynamics_address: "object:counter" } + }, + senses: { + "sense:counter": { + dynamics_senses: ["sense:counter"], + output: "simfile.numeric-observation.v1", + project: projectSense + } + } +}); diff --git a/fixtures/sims/public-dynamics-contract/systems/runtime-contract.ts b/fixtures/sims/public-dynamics-contract/systems/runtime-contract.ts new file mode 100644 index 0000000..22a03c3 --- /dev/null +++ b/fixtures/sims/public-dynamics-contract/systems/runtime-contract.ts @@ -0,0 +1,126 @@ +import { + canonicalDynamicsJson, + parseDynamicsActionAttempt, + parseDynamicsProvenance, + createDynamicsBuildReceipt, + parseDynamicsSessionSnapshot, + persistDynamicsBuild, + prepareDynamicsBuild +} from "simfile/dynamics"; + +/** + * Deliberately inert: this source proves the fixture can name the supported + * runtime boundary without reaching into Simfile's source tree. + */ +export const publicDynamicsRuntime = { + canonicalDynamicsJson, + parseDynamicsActionAttempt, + parseDynamicsProvenance, + createDynamicsBuildReceipt, + parseDynamicsSessionSnapshot, + persistDynamicsBuild, + prepareDynamicsBuild +}; + +const expectRejected = (operation: () => unknown, pattern: RegExp): void => { + let error: unknown; + try { + operation(); + } catch (caught) { + error = caught; + } + if (!(error instanceof Error) || !pattern.test(error.message)) { + throw new Error(`expected public dynamics parser rejection matching ${pattern}`); + } +}; + +export const exercisePublicDynamicsRuntime = async (input: { + evidenceRoot: string; + moduleReference: string; + scratchRoot: string; + simfilePath: string; +}): Promise<{ + artifactPath: string; + evidenceArtifactPath: string; + evidenceReceiptPath: string; + receiptPath: string; +}> => { + const action = { + act_id: "external-action", + action: "increment", + actor: "entity:counter", + at_tick: 0, + input: {}, + origin: "external", + principal_id: "external-principal", + target: "entity:counter" + }; + const parsedAction = parseDynamicsActionAttempt(action); + if (parsedAction.act_id !== action.act_id) throw new Error("action parser changed the valid action"); + expectRejected(() => parseDynamicsActionAttempt({ ...action, origin: "hostile" }), /origin is invalid/u); + + const provenance = { + api_version: "simfile.dynamics-provider.v1", + config_sha256: "0".repeat(64), + module: "./systems/contract.ts", + module_sha256: "1".repeat(64), + node_version: "external-node", + numeric_model: "ieee754-binary64", + provider_dependencies: {}, + provider_id: "public-contract-counter", + provider_version: "1.0.0", + state_schema_version: "public-contract-counter.v1" + }; + const parsedProvenance = parseDynamicsProvenance(provenance); + if (parsedProvenance.provider_id !== provenance.provider_id) throw new Error("provenance parser changed the valid provenance"); + expectRejected(() => parseDynamicsProvenance({ ...provenance, numeric_model: "hostile" }), /contract identity is invalid/u); + + const snapshot = { + accepted_action_sequences: { above_floor: [], floor: 1 }, + action_ingress: [], + action_ingress_floor: 1, + action_ingress_ordinal: 0, + next_action_sequence: 1, + next_event_sequence: 1, + next_tick: 0, + pending_actions: [], + provider_state: { value: 0 }, + provenance, + resolved_action_sequences: { above_floor: [], floor: 1 }, + seed: "external-seed", + sim_seconds_per_tick: 0.02, + version: "simfile.dynamics-snapshot.v1" + }; + const parsedSnapshot = parseDynamicsSessionSnapshot(snapshot); + if (parsedSnapshot.next_tick !== 0) throw new Error("snapshot parser changed the valid snapshot"); + expectRejected(() => parseDynamicsSessionSnapshot({ ...snapshot, version: "hostile" }), /invalid simfile dynamics snapshot version/u); + if (canonicalDynamicsJson({ b: 2, a: 1 }) !== '{"a":1,"b":2}') { + throw new Error("canonical dynamics JSON is not canonical"); + } + + const prepared = await prepareDynamicsBuild(input.simfilePath, input.moduleReference); + const receipt = await createDynamicsBuildReceipt(input.simfilePath, prepared); + const lifecycle = await persistDynamicsBuild({ + absoluteSimfilePath: input.simfilePath, + evidenceRoot: input.evidenceRoot, + prepared, + receipt, + scratchRoot: input.scratchRoot + }); + await lifecycle.verify(); + const imported = await lifecycle.importArtifact(); + if (typeof imported.createDynamicsProvider !== "function") { + throw new Error("persisted public dynamics artifact did not import its provider factory"); + } + const evidence = await lifecycle.copyEvidence(); + if (!evidence) throw new Error("public dynamics lifecycle did not copy evidence"); + const paths = { + artifactPath: lifecycle.artifactPath, + evidenceArtifactPath: evidence.artifactPath, + evidenceReceiptPath: evidence.receiptPath, + receiptPath: lifecycle.receiptPath + }; + await lifecycle.cleanup(); + await lifecycle.cleanup(); + return paths; +}; diff --git a/package-lock.json b/package-lock.json index eda9ebe..6c50a29 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,19 @@ { "name": "simfile", - "version": "0.0.1", + "version": "0.0.2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "simfile", - "version": "0.0.1", + "version": "0.0.2", "license": "MIT", "dependencies": { - "@noopolis/stele": "^0.0.1", + "@modelcontextprotocol/sdk": "1.29.0", + "@noopolis/stele": "0.0.2", + "@types/node": "24.12.4", + "esbuild": "0.28.1", + "typescript": "5.9.3", "yaml": "^2.8.2", "zod": "^4.1.12" }, @@ -19,7 +23,6 @@ "devDependencies": { "@glyphcss/core": "^0.1.0", "@glyphcss/react": "^0.1.0", - "@types/node": "^24.12.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", @@ -27,11 +30,10 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "tsx": "^4.21.0", - "typescript": "^5.9.3", "vite": "^6.0.0" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.19.0" } }, "node_modules/@babel/code-frame": { @@ -323,7 +325,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -340,7 +341,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -357,7 +357,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -374,7 +373,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -391,7 +389,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -408,7 +405,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -425,7 +421,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -442,7 +437,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -459,7 +453,6 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -476,7 +469,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -493,7 +485,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -510,7 +501,6 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -527,7 +517,6 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -544,7 +533,6 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -561,7 +549,6 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -578,7 +565,6 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -595,7 +581,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -612,7 +597,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -629,7 +613,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -646,7 +629,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -663,7 +645,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -680,7 +661,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -697,7 +677,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -714,7 +693,6 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -731,7 +709,6 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -748,7 +725,6 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ @@ -780,6 +756,18 @@ "react-dom": "^18.0.0 || ^19.0.0" } }, + "node_modules/@hono/node-server": { + "version": "1.19.17", + "resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.17.tgz", + "integrity": "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ==", + "license": "MIT", + "engines": { + "node": ">=18.14.1" + }, + "peerDependencies": { + "hono": "^4" + } + }, "node_modules/@jridgewell/gen-mapping": { "version": "0.3.13", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", @@ -830,10 +818,50 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@modelcontextprotocol/sdk": { + "version": "1.29.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz", + "integrity": "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==", + "license": "MIT", + "dependencies": { + "@hono/node-server": "^1.19.9", + "ajv": "^8.17.1", + "ajv-formats": "^3.0.1", + "content-type": "^1.0.5", + "cors": "^2.8.5", + "cross-spawn": "^7.0.5", + "eventsource": "^3.0.2", + "eventsource-parser": "^3.0.0", + "express": "^5.2.1", + "express-rate-limit": "^8.2.1", + "hono": "^4.11.4", + "jose": "^6.1.3", + "json-schema-typed": "^8.0.2", + "pkce-challenge": "^5.0.0", + "raw-body": "^3.0.0", + "zod": "^3.25 || ^4.0", + "zod-to-json-schema": "^3.25.1" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@cfworker/json-schema": "^4.1.1", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "@cfworker/json-schema": { + "optional": true + }, + "zod": { + "optional": false + } + } + }, "node_modules/@noopolis/stele": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/@noopolis/stele/-/stele-0.0.1.tgz", - "integrity": "sha512-93IXstDZ3/bimYmp0yNAkGe91cnaGzjo4+Zn1qCEDWoSKTlOk2SznJwAZfOtucso3YpxpXJMXXxq1C47UjZE6g==", + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@noopolis/stele/-/stele-0.0.2.tgz", + "integrity": "sha512-4Di+j+HJDZYUtZMrvthMNZ7ERPPBHcp0T6bBBJaj9BXp8/YZD/iUikJk5bf/MvPnVqjcsIG151MTIj9wnAxwwQ==", "license": "MIT", "dependencies": { "zod": "^4.1.12" @@ -1252,13 +1280,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.13.3", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.3.tgz", - "integrity": "sha512-Dh8vAsV36ig5wa9OX4pXvMc9D3Veibfw2wix0CUwYODLD8nkj9UsLjASr49nPg+2eKzxhBV+v7L8pXvT4e639Q==", - "dev": true, + "version": "24.12.4", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.4.tgz", + "integrity": "sha512-GUUEShf+PBCGW2KaXwcIt3Yk+e3pkKwWKb9GSyM9WQVE+ep2jzmHdGsHzu4wgcZy5fN9FBdVzjpBQsYlpfpgLA==", "license": "MIT", "dependencies": { - "undici-types": "~7.18.0" + "undici-types": "~7.16.0" } }, "node_modules/@types/react": { @@ -1302,6 +1329,52 @@ "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz", + "integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==", + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, "node_modules/baseline-browser-mapping": { "version": "2.10.43", "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.43.tgz", @@ -1315,6 +1388,43 @@ "node": ">=6.0.0" } }, + "node_modules/body-parser": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/browserslist": { "version": "4.28.5", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.5.tgz", @@ -1349,6 +1459,44 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/bytes": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz", + "integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/caniuse-lite": { "version": "1.0.30001803", "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001803.tgz", @@ -1370,6 +1518,28 @@ ], "license": "CC-BY-4.0" }, + "node_modules/content-disposition": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz", + "integrity": "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/convert-source-map": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", @@ -1377,6 +1547,55 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz", + "integrity": "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, "node_modules/csstype": { "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", @@ -1388,7 +1607,6 @@ "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, "license": "MIT", "dependencies": { "ms": "^2.1.3" @@ -1402,6 +1620,35 @@ } } }, + "node_modules/depd": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", + "integrity": "sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", + "integrity": "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==", + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.389", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.389.tgz", @@ -1409,11 +1656,49 @@ "dev": true, "license": "ISC" }, + "node_modules/encodeurl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.2.tgz", + "integrity": "sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/esbuild": { "version": "0.28.1", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", - "dev": true, "hasInstallScript": true, "license": "MIT", "bin": { @@ -1461,6 +1746,126 @@ "node": ">=6" } }, + "node_modules/escape-html": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", + "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", + "license": "MIT" + }, + "node_modules/etag": { + "version": "1.8.1", + "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", + "integrity": "sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/eventsource": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/eventsource/-/eventsource-3.0.7.tgz", + "integrity": "sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==", + "license": "MIT", + "dependencies": { + "eventsource-parser": "^3.0.1" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/eventsource-parser": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/eventsource-parser/-/eventsource-parser-3.1.0.tgz", + "integrity": "sha512-kJezFj9YFAMLeORyi7aCLxLbD5/qWMQnoMVlVPyHIll7lgRJCc3JVln9Vgl9nwQi0YkMnhdGTMNn7CkRRAptMg==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/express": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express-rate-limit": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.6.0.tgz", + "integrity": "sha512-XKJXDsASUOo0LLtFwW5hCcQGH0N4WQc/Rn8/Pvoia+TJFOkkFPvrtW9lZOeeNcxQJspvOIERMwiRLsVFlhHEkA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "ip-address": "^10.2.0" + }, + "engines": { + "node": ">= 16" + }, + "funding": { + "url": "https://github.com/sponsors/express-rate-limit" + }, + "peerDependencies": { + "express": ">= 4.11" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.5.tgz", + "integrity": "sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/fdir": { "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", @@ -1479,6 +1884,45 @@ } } }, + "node_modules/finalhandler": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", + "integrity": "sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/fsevents": { "version": "2.3.3", "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", @@ -1494,6 +1938,15 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -1504,14 +1957,177 @@ "node": ">=6.9.0" } }, - "node_modules/glyphcss": { - "version": "0.1.0", - "resolved": "https://registry.npmjs.org/glyphcss/-/glyphcss-0.1.0.tgz", - "integrity": "sha512-AyMvlsOmpEgKF2MU9RKWK1Fsna69YrkmtpthbYwRxG9g/909mek3UstpmivSUoXHLyJ8ETUt2fbA1pnRdXxe6w==", - "dev": true, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", "license": "MIT", "dependencies": { - "@glyphcss/core": "^0.1.0" + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glyphcss": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/glyphcss/-/glyphcss-0.1.0.tgz", + "integrity": "sha512-AyMvlsOmpEgKF2MU9RKWK1Fsna69YrkmtpthbYwRxG9g/909mek3UstpmivSUoXHLyJ8ETUt2fbA1pnRdXxe6w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@glyphcss/core": "^0.1.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", + "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hono": { + "version": "4.13.2", + "resolved": "https://registry.npmjs.org/hono/-/hono-4.13.2.tgz", + "integrity": "sha512-JydRilDRkYBQMt9qR9U92mXxmbGqsqSn/IKOrh4e7/gEbn+0zSr8igTu0obwJoNGN4sez28DIql7FBHWydoJpA==", + "license": "MIT", + "engines": { + "node": ">=16.9.0" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "license": "ISC" + }, + "node_modules/ip-address": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.5.0.tgz", + "integrity": "sha512-R5SnVLJmgYYvf2F2ZgwSBnelz5G4q5AxIC277GDfUaNbrZKNANcBC7RHqYYePlszf4kBolVkJauG0ZjHHFh55g==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", + "integrity": "sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jose": { + "version": "6.2.3", + "resolved": "https://registry.npmjs.org/jose/-/jose-6.2.3.tgz", + "integrity": "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/panva" } }, "node_modules/js-tokens": { @@ -1534,6 +2150,18 @@ "node": ">=6" } }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-1.0.0.tgz", + "integrity": "sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==", + "license": "MIT" + }, + "node_modules/json-schema-typed": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/json-schema-typed/-/json-schema-typed-8.0.2.tgz", + "integrity": "sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==", + "license": "BSD-2-Clause" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -1557,11 +2185,65 @@ "yallist": "^3.0.2" } }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.0.tgz", + "integrity": "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/mime-db": { + "version": "1.54.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", + "integrity": "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/ms": { "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -1583,6 +2265,15 @@ "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" } }, + "node_modules/negotiator": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/node-releases": { "version": "2.0.51", "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.51.tgz", @@ -1593,6 +2284,76 @@ "node": ">=18" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/on-finished": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/on-finished/-/on-finished-2.4.1.tgz", + "integrity": "sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", + "integrity": "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -1613,6 +2374,15 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pkce-challenge": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/pkce-challenge/-/pkce-challenge-5.0.1.tgz", + "integrity": "sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==", + "license": "MIT", + "engines": { + "node": ">=16.20.0" + } + }, "node_modules/postcss": { "version": "8.5.16", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz", @@ -1642,6 +2412,63 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", + "integrity": "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/qs": { + "version": "6.15.3", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz", + "integrity": "sha512-O9gl3zCl5h5blw1KGUzQKhA5oUXSl8rwUIM5o0S3nCXMliSvy5Dzx7/DJcI+SwgICv+IneSZwhBh1oSyEHA71A==", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/react": { "version": "19.2.7", "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", @@ -1675,6 +2502,15 @@ "node": ">=0.10.0" } }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/rollup": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", @@ -1720,6 +2556,28 @@ "fsevents": "~2.3.2" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "license": "MIT" + }, "node_modules/scheduler": { "version": "0.27.0", "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", @@ -1737,6 +2595,150 @@ "semver": "bin/semver.js" } }, + "node_modules/send": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz", + "integrity": "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==", + "license": "ISC" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/side-channel": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.1.tgz", + "integrity": "sha512-6x6dK6zJdpTzF4sQeNYxwtvBzf6Eg4GtlesS94HOvTudUeyK2WXAaIfmDgsyslYrRBeFIlsi54AYsFGUuhmvrQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -1747,6 +2749,15 @@ "node": ">=0.10.0" } }, + "node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/tinyglobby": { "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", @@ -1764,6 +2775,15 @@ "url": "https://github.com/sponsors/SuperchupuDev" } }, + "node_modules/toidentifier": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/toidentifier/-/toidentifier-1.0.1.tgz", + "integrity": "sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, "node_modules/tsx": { "version": "4.23.0", "resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.0.tgz", @@ -1783,11 +2803,41 @@ "fsevents": "~2.3.3" } }, + "node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/typescript": { "version": "5.9.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, "license": "Apache-2.0", "bin": { "tsc": "bin/tsc", @@ -1798,12 +2848,20 @@ } }, "node_modules/undici-types": { - "version": "7.18.2", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", - "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", - "dev": true, + "version": "7.16.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz", + "integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==", "license": "MIT" }, + "node_modules/unpipe": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", + "integrity": "sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -1835,6 +2893,15 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/vary": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", + "integrity": "sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/vite": { "version": "6.4.3", "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.3.tgz", @@ -2394,6 +3461,27 @@ "@esbuild/win32-x64": "0.25.12" } }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", @@ -2424,6 +3512,15 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } + }, + "node_modules/zod-to-json-schema": { + "version": "3.25.2", + "resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz", + "integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==", + "license": "ISC", + "peerDependencies": { + "zod": "^3.25.28 || ^4" + } } } } diff --git a/package.json b/package.json index 6296082..2645cf3 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "simfile", - "version": "0.0.1", + "version": "0.0.2", "description": "Declarative simulation world mechanics for agentic organizations.", "license": "MIT", "type": "module", @@ -17,6 +17,66 @@ "./schema": { "types": "./dist/schema/index.d.ts", "import": "./dist/schema/index.js" + }, + "./moltnet": { + "types": "./dist/moltnet/index.d.ts", + "import": "./dist/moltnet/index.js" + }, + "./dynamics": { + "types": "./dist/dynamics/index.d.ts", + "import": "./dist/dynamics/index.js" + }, + "./observe": { + "types": "./dist/observe/index.d.ts", + "import": "./dist/observe/index.js" + }, + "./runtime": { + "types": "./dist/runtime/index.d.ts", + "import": "./dist/runtime/index.js" + }, + "./compose": { + "types": "./dist/compose/index.d.ts", + "import": "./dist/compose/index.js" + }, + "./spawnfile": { + "types": "./dist/spawnfile/index.d.ts", + "import": "./dist/spawnfile/index.js" + }, + "./world-artifact": { + "types": "./dist/world-artifact/index.d.ts", + "import": "./dist/world-artifact/index.js" + }, + "./world-artifact/composer": { + "types": "./dist/world-artifact/entrypoint.d.ts", + "import": "./dist/world-artifact/entrypoint.js" + }, + "./world": { + "types": "./dist/world/index.d.ts", + "import": "./dist/world/index.js" + }, + "./world-server": { + "types": "./dist/world-server/index.d.ts", + "import": "./dist/world-server/index.js" + }, + "./world-surface": { + "types": "./dist/world-surface/index.d.ts", + "import": "./dist/world-surface/index.js" + }, + "./viewer-extension": { + "types": "./dist/viewer-extension/index.d.ts", + "import": "./dist/viewer-extension/index.js" + }, + "./viewer-extension/descriptor": { + "types": "./dist/viewer-extension/descriptor.d.ts", + "import": "./dist/viewer-extension/descriptor.js" + }, + "./test-support/locked-port": { + "types": "./dist/test-support/lockedPort.d.ts", + "import": "./dist/test-support/lockedPort.js" + }, + "./test-support/world-controller": { + "types": "./dist/test-support/worldController.d.ts", + "import": "./dist/test-support/worldController.js" } }, "files": [ @@ -36,22 +96,29 @@ "prepack": "npm run build", "prepublishOnly": "npm run typecheck && npm test", "typecheck": "tsc --project tsconfig.json --noEmit && tsc --project tsconfig.web.json --noEmit", - "test": "node --import tsx --test \"src/**/*.test.ts\" \"web/src/**/*.test.ts\"", + "pretest": "npm run build", + "test": "node scripts/run-tests.mjs", "coverage:render": "node --import tsx scripts/render-coverage.ts", - "emit-causal-fixture": "tsx src/runtime/emit-causal-fixture.ts" + "verify:package-closure": "node tools/verify-package-closure.mjs", + "emit-causal-fixture": "tsx src/runtime/emit-causal-fixture.ts", + "hooks:install": "node .githooks/install.mjs", + "prepare": "node .githooks/install.mjs" }, "engines": { - "node": ">=22.0.0" + "node": ">=22.19.0" }, "dependencies": { - "@noopolis/stele": "^0.0.1", + "@modelcontextprotocol/sdk": "1.29.0", + "@noopolis/stele": "0.0.2", + "@types/node": "24.12.4", + "esbuild": "0.28.1", + "typescript": "5.9.3", "yaml": "^2.8.2", "zod": "^4.1.12" }, "devDependencies": { "@glyphcss/core": "^0.1.0", "@glyphcss/react": "^0.1.0", - "@types/node": "^24.12.4", "@types/react": "^19.2.14", "@types/react-dom": "^19.2.3", "@vitejs/plugin-react": "^5.1.4", @@ -59,7 +126,6 @@ "react": "^19.2.6", "react-dom": "^19.2.6", "tsx": "^4.21.0", - "typescript": "^5.9.3", "vite": "^6.0.0" } } diff --git a/scripts/run-tests.mjs b/scripts/run-tests.mjs new file mode 100644 index 0000000..b69c235 --- /dev/null +++ b/scripts/run-tests.mjs @@ -0,0 +1,73 @@ +import { spawn } from "node:child_process"; + +export const defaultTestArguments = ["src/**/*.test.ts", "web/src/**/*.test.ts"]; +export const nodeTestArguments = (testArguments) => [ + "--import", "tsx", "--test", "--test-reporter=tap", ...testArguments, +]; + +const summaryPattern = /^# (tests|pass|fail|cancelled|skipped) (\d+)\s*$/gmu; + +export const parseTapSummary = (output) => { + const counts = {}; + for (const match of String(output).matchAll(summaryPattern)) { + counts[match[1]] = Number(match[2]); + } + const required = ["tests", "pass", "fail", "cancelled", "skipped"]; + if (required.some((name) => !Number.isInteger(counts[name]))) { + throw new Error("test run summary could not be parsed"); + } + return counts; +}; + +export const adjudicateSummary = (summary, nodeExitCode) => { + if (summary.cancelled > 0) { + return { + message: `test run is not green: ${summary.cancelled} test(s) cancelled — a cancelled test did not run`, + exitCode: nodeExitCode || 1, + }; + } + if (summary.pass === 0) { + return { + message: "test run proved nothing: 0 tests passed (matched 0 files?)", + exitCode: nodeExitCode || 1, + }; + } + if (nodeExitCode !== 0) return { message: null, exitCode: nodeExitCode || 1 }; + return { message: null, exitCode: 0 }; +}; + +export const runTests = (testArguments = defaultTestArguments) => new Promise((resolve) => { + const child = spawn(process.execPath, nodeTestArguments(testArguments), { + stdio: ["inherit", "pipe", "pipe"], + }); + let output = ""; + child.stdout.on("data", (chunk) => { + output += chunk; + process.stdout.write(chunk); + }); + child.stderr.on("data", (chunk) => { + output += chunk; + process.stderr.write(chunk); + }); + child.on("error", (error) => { + console.error(`test runner could not start: ${error.message}`); + resolve(1); + }); + child.on("close", (nodeExitCode) => { + let summary; + try { + summary = parseTapSummary(output); + } catch (error) { + if (nodeExitCode === 0) console.error(`test run is not green: ${error.message}`); + resolve(nodeExitCode || 1); + return; + } + const verdict = adjudicateSummary(summary, nodeExitCode); + if (verdict.message) console.error(verdict.message); + resolve(verdict.exitCode); + }); +}); + +if (import.meta.url === `file://${process.argv[1]}`) { + process.exitCode = await runTests(process.argv.slice(2).length > 0 ? process.argv.slice(2) : defaultTestArguments); +} diff --git a/scripts/run-tests.test.mjs b/scripts/run-tests.test.mjs new file mode 100644 index 0000000..8138c96 --- /dev/null +++ b/scripts/run-tests.test.mjs @@ -0,0 +1,29 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { adjudicateSummary, nodeTestArguments, parseTapSummary } from "./run-tests.mjs"; + +test("pins TAP before every requested test pattern", () => { + assert.deepEqual(nodeTestArguments(["src/example.test.ts"]), [ + "--import", "tsx", "--test", "--test-reporter=tap", "src/example.test.ts", + ]); +}); + +test("rejects a run with zero passed tests even when Node exits successfully", () => { + const summary = parseTapSummary("# tests 0\n# pass 0\n# fail 0\n# cancelled 0\n# skipped 0\n"); + const verdict = adjudicateSummary(summary, 0); + assert.equal(verdict.exitCode, 1); + assert.match(verdict.message, /proved nothing/); +}); + +test("rejects cancelled tests and names their count", () => { + const summary = parseTapSummary("# tests 13\n# pass 0\n# fail 0\n# cancelled 13\n# skipped 0\n"); + const verdict = adjudicateSummary(summary, 1); + assert.equal(verdict.exitCode, 1); + assert.match(verdict.message, /13 test\(s\) cancelled/); +}); + +test("allows a healthy passing run", () => { + const summary = parseTapSummary("# tests 7\n# pass 7\n# fail 0\n# cancelled 0\n# skipped 0\n"); + assert.deepEqual(adjudicateSummary(summary, 0), { message: null, exitCode: 0 }); +}); diff --git a/src/AGENTS.md b/src/AGENTS.md index 9d0d525..bb552ba 100644 --- a/src/AGENTS.md +++ b/src/AGENTS.md @@ -5,12 +5,17 @@ This package defines the Simfile schema and CLI. ## Structure - `schema/` contains the Zod schema, parser, and semantic validation helpers. +- `dynamics/` contains the trusted local-module mechanics contract and checked + host session. - `cli/` contains the thin `simfile` command wrapper. - `index.ts` is the public package barrel. ## Rules -- Keep simulation semantics declarative. Do not add runtime orchestration here. +- Keep simulation semantics declarative. Lifecycle composition belongs only in + its dedicated generic layer; do not add agent orchestration anywhere. +- `run/` remains the timer-free local deterministic writer. It must not acquire + target, organization, service-supervision, or agent-cognition responsibilities. - Named exports only. - Keep tests co-located with the module they cover. - Avoid importing Spawnfile internals. Simfile can reference Spawnfile ids as strings. diff --git a/src/cli/AGENTS.md b/src/cli/AGENTS.md index 72f5963..25df710 100644 --- a/src/cli/AGENTS.md +++ b/src/cli/AGENTS.md @@ -5,3 +5,19 @@ This folder contains the thin command-line wrapper. Business logic belongs in `src/schema/` (validate/run) or `src/observe/` (observe); command handlers should only parse arguments, read files, call those modules, and format output. + +`recover.ts` is the thin public restart route for durable composed journals. + +`runArguments.ts` parses the complete run flag matrix before authority opens. +`runRoute.ts` resolves authored `spawnfile:` linkage and selects composed or +explicit local execution. `composedRunCommand.ts` is the single thin production +adapter into the generic composed supervisor. `composedRunBootstrap.ts` binds a +fixture declaration to public Spawnfile CLI receipts without owning private +target configuration. `compiledOrganizationIdentity.ts` keeps Spawnfile's short +compile fingerprint distinct from the domain-separated composed artifact digest. +`credentialBindingProjection.ts` projects logical credential aliases consistently +across provisioning, world-member references, and private target mount names. +`composedRunArtifacts.ts` reconciles exported owner +evidence into the atomic composed run record before sealing. +`composedViewerBinding.ts` corroborates the host-only viewer data mapping +against trusted project extension ids before reserving the record. diff --git a/src/cli/compiledOrganizationIdentity.test.ts b/src/cli/compiledOrganizationIdentity.test.ts new file mode 100644 index 0000000..71fa266 --- /dev/null +++ b/src/cli/compiledOrganizationIdentity.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + compileReportMemberEngines, + compileReportMoltnetReleaseExpectation, + deriveCompiledOrganizationArtifactDigest, + parseSpawnfileCompileReport, +} from "./compiledOrganizationIdentity.js"; + +const release = { + architecture: "amd64", + asset: "moltnet_linux_amd64.tar.gz", + asset_sha256: `sha256:${"a".repeat(64)}`, + capabilities: ["pi-bridge"], + release_version: "v0.1.0", + source_revision: "b".repeat(40), + version: "spawnfile.moltnet-release-identity.v1", +} as const; + +const report = (fingerprint: string, engines: readonly Readonly>[]) => ({ + compile_fingerprint: fingerprint, + container: { + moltnet: { release }, + runtime_instances: engines.map((engine_by_node_id) => ({ engine_by_node_id })), + }, +}); + +test("keeps Spawnfile compile fingerprints distinct from composed artifact digests", () => { + const parsed = parseSpawnfileCompileReport(report("sf1:0123456789ab", [{ + "agent:blue": "pi", + }])); + const artifact = deriveCompiledOrganizationArtifactDigest(parsed.compile_fingerprint); + assert.match(parsed.compile_fingerprint, /^sf1:[a-f0-9]{12}$/u); + assert.equal(artifact, + "sha256:bed587cd84d207b29c02c2ef80f519d1849a59cff1b2d28c4ce2e0a645f7a8e5"); + assert.equal(artifact, deriveCompiledOrganizationArtifactDigest(parsed.compile_fingerprint)); + assert.throws(() => parseSpawnfileCompileReport(report(`sha256:${"c".repeat(64)}`, [{ + "agent:blue": "pi", + }]))); +}); + +test("requires one consistent engine assignment per compiled member", () => { + const parsed = parseSpawnfileCompileReport(report("sf1:0123456789ab", [ + { "agent:blue": "pi" }, { "agent:blue": "pi", "agent:red": "pi" }, + ])); + assert.deepEqual(compileReportMemberEngines(parsed), { + "agent:blue": "pi", "agent:red": "pi", + }); + const contradictory = parseSpawnfileCompileReport(report("sf1:0123456789ab", [ + { "agent:blue": "pi" }, { "agent:blue": "other" }, + ])); + assert.throws(() => compileReportMemberEngines(contradictory), /contradictory/u); +}); + +test("projects the public Moltnet identity onto the strict lifecycle expectation", () => { + const parsed = parseSpawnfileCompileReport(report("sf1:0123456789ab", [{ + "agent:blue": "pi", + }])); + assert.deepEqual(compileReportMoltnetReleaseExpectation(parsed), { + architecture: "amd64", + asset_sha256: `sha256:${"a".repeat(64)}`, + release_version: "v0.1.0", + source_revision: "b".repeat(40), + }); +}); diff --git a/src/cli/compiledOrganizationIdentity.ts b/src/cli/compiledOrganizationIdentity.ts new file mode 100644 index 0000000..535d24a --- /dev/null +++ b/src/cli/compiledOrganizationIdentity.ts @@ -0,0 +1,71 @@ +import { z } from "zod"; + +import { digestComposedJson } from "../compose/json.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const compileFingerprint = z.string().regex(/^sf1:[a-f0-9]{12}$/u); + +const compileReportSchema = z.object({ + compile_fingerprint: compileFingerprint, + container: z.object({ + moltnet: z.object({ + release: z.object({ + architecture: z.enum(["amd64", "arm64"]), + asset: z.string().min(1), + asset_sha256: digest, + capabilities: z.tuple([z.literal("pi-bridge")]), + release_version: z.string().min(1), + source_revision: z.string().regex(/^[a-f0-9]{40}$/u), + version: z.literal("spawnfile.moltnet-release-identity.v1"), + }).strict(), + }).passthrough(), + runtime_instances: z.array(z.object({ + engine_by_node_id: z.record(z.string().min(1), z.string().min(1)), + }).passthrough()).min(1), + }).passthrough(), +}).passthrough(); + +export type SpawnfileCompileReport = z.infer; + +export const parseSpawnfileCompileReport = (raw: unknown): SpawnfileCompileReport => + compileReportSchema.parse(raw); + +export const deriveCompiledOrganizationArtifactDigest = ( + compile_fingerprint: string, +): `sha256:${string}` => digestComposedJson( + "simfile.spawnfile-compiled-organization-artifact.v1", + { compile_fingerprint: compileFingerprint.parse(compile_fingerprint) }, +); + +export const compileReportMemberEngines = ( + report: SpawnfileCompileReport, +): Readonly> => { + const result: Record = {}; + for (const runtime of report.container.runtime_instances) { + for (const [member, engine] of Object.entries(runtime.engine_by_node_id)) { + if (result[member] !== undefined && result[member] !== engine) { + throw new TypeError("Spawnfile member engine assignment is contradictory"); + } + result[member] = engine; + } + } + if (Object.keys(result).length < 1) throw new TypeError("Spawnfile member engines are absent"); + return Object.freeze(result); +}; + +export const compileReportMoltnetReleaseExpectation = ( + report: SpawnfileCompileReport, +): Readonly<{ + architecture: "amd64" | "arm64"; + asset_sha256: string; + release_version: string; + source_revision: string; +}> => { + const release = report.container.moltnet.release; + return Object.freeze({ + architecture: release.architecture, + asset_sha256: release.asset_sha256, + release_version: release.release_version, + source_revision: release.source_revision, + }); +}; diff --git a/src/cli/composedRouting.test.ts b/src/cli/composedRouting.test.ts new file mode 100644 index 0000000..c9bc7dd --- /dev/null +++ b/src/cli/composedRouting.test.ts @@ -0,0 +1,57 @@ +import assert from "node:assert/strict"; +import { mkdtemp, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import type { LinkedComposedRunInput } from "./composedRunCommand.js"; +import { runCli } from "./index.js"; + +const project = async (): Promise<{ directory: string; simfile: string }> => { + const directory = await mkdtemp(path.join(tmpdir(), "simfile-composed-route-")); + const simfile = path.join(directory, "Simfile"); + await writeFile(path.join(directory, "Spawnfile"), "kind: agent\n", "utf8"); + await writeFile(simfile, ` +simfile_version: "0.1" +name: composed-route +spawnfile: ./Spawnfile +clock: + seed: composed-route + tick: 1s +`, "utf8"); + return { directory, simfile }; +}; + +describe("linked composed CLI dispatch", () => { + it("passes existing composed flags and the resolved link to one command", async () => { + const fixture = await project(); + let seen: LinkedComposedRunInput | undefined; + const code = await runCli([ + "run", fixture.simfile, "--view", "--out=record", "--seed", "alternate", + "--run-id=run-one", + ], { runComposed: async (input) => { seen = input; return 0; } }); + assert.equal(code, 0); + assert.equal(seen?.linked_spawnfile_path, path.join(fixture.directory, "Spawnfile")); + assert.equal(seen?.options.view, true); + assert.equal(seen?.options.outDir, "record"); + assert.equal(seen?.options.seed, "alternate"); + assert.equal(seen?.options.runId, "run-one"); + }); + + it("rejects incompatible flags before invoking or creating output", async () => { + const fixture = await project(); + for (const args of [ + ["--ticks", "1"], ["--acts=acts.json"], ["--clock=2026-08-07T00:00:00Z"], + ["--moltnet-artifact=delivery"], ["--spawnfile-report=report.json"], + ]) { + let invoked = false; + const out = path.join(fixture.directory, `out-${args[0]!.slice(2, 8)}`); + assert.equal(await runCli(["run", fixture.simfile, "--out", out, ...args], { + runComposed: async () => { invoked = true; return 0; }, + }), 1); + assert.equal(invoked, false); + await assert.rejects(stat(out)); + } + }); +}); + diff --git a/src/cli/composedRunArtifacts.test.ts b/src/cli/composedRunArtifacts.test.ts new file mode 100644 index 0000000..472d43d --- /dev/null +++ b/src/cli/composedRunArtifacts.test.ts @@ -0,0 +1,84 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { test } from "node:test"; + +import type { ComposedArtifactRole } from "../compose/runRecord.js"; +import type { LinkedComposedBootstrap } from "./composedRunBootstrap.js"; +import { createLinkedComposedRecord } from "./composedRunArtifacts.js"; + +const requiredRoles = [ + "accepted-action", "action-result", "authority-export", "identity", "probe", + "provenance", "terminal", "world-checkpoint", "world-frame", +] as const satisfies readonly ComposedArtifactRole[]; + +test("linked composed record seals declared viewer data and projection authority", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-linked-viewer-record-")); + try { + const projectRoot = path.join(root, "project"); + const assets = path.join(projectRoot, "assets"); + await mkdir(assets, { recursive: true }); + await writeFile(path.join(projectRoot, "extension.js"), "export {};\n"); + await writeFile(path.join(assets, "manifest.json"), "{}\n"); + await writeFile(path.join(projectRoot, "extension.json"), `${JSON.stringify({ + asset_root: "./assets", + id: "fixture-renderer", + module: "./extension.js", + version: "simfile.viewer-extension.v1", + })}\n`); + await writeFile(path.join(projectRoot, "viewer-extensions.json"), `${JSON.stringify({ + extensions: [{ descriptor: "./extension.json", id: "fixture-renderer" }], + version: "simfile.project-viewer-extensions.v1", + })}\n`); + const bootstrap = { + compile_fingerprint: `sha256:${"1".repeat(32)}`, + preparation: { + viewer: { + extensions: [{ id: "fixture-renderer", + recorded_artifact: "presentation/viewer-trace.json" }], + live_trace: { + artifact: { id: "viewer_trace", max_bytes: 120_000, + media_type: "application/json", + path: "/tmp/spawnfile-public/viewer-trace.json" }, + extension_id: "fixture-renderer", + }, + version: "simfile.composed-viewer-binding.v1", + }, + }, + request: { world: { + artifact_manifest_digest: `sha256:${"2".repeat(64)}`, + bundle_digest: `sha256:${"3".repeat(64)}`, + runtime_abi: "simfile.world-sidecar-runtime.v1", + } }, + run_id: "linked-viewer-record", + run_path: path.join(root, "run"), + trusted_project_root: projectRoot, + } as unknown as LinkedComposedBootstrap; + const record = await createLinkedComposedRecord(bootstrap); + await record.writeArtifact({ + bytes: Buffer.from("{}\n"), + path: "presentation/viewer-trace.json", + role: "presentation", + }); + for (const [index, role] of requiredRoles.entries()) { + await record.writeArtifact({ + bytes: Buffer.from(`${role}\n`), + path: `test/${String(index).padStart(2, "0")}-${role}.txt`, + role, + }); + } + const sealed = await record.seal(); + assert.deepEqual(sealed.manifest.world?.viewer_extension_data, { + "fixture-renderer": "presentation/viewer-trace.json", + }); + assert.equal(sealed.manifest.world?.viewer_projection, + "presentation/viewer-trace.json"); + assert.equal(sealed.manifest.contract_versions["simfile.composed-viewer-binding.v1"], + "simfile.composed-viewer-binding.v1"); + assert.equal(sealed.manifest.artifacts.some(({ path: artifactPath }) => + artifactPath === "viewer-extensions.json"), true); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/src/cli/composedRunArtifacts.ts b/src/cli/composedRunArtifacts.ts new file mode 100644 index 0000000..8a7ab5f --- /dev/null +++ b/src/cli/composedRunArtifacts.ts @@ -0,0 +1,133 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { + canonicalComposedJson, +} from "../compose/json.js"; +import { + composedPhasePayload, + createComposedRunRecord, + parseComposedOrganizationEvidenceReceipt, + parseComposedWorldEvidenceReceipt, + type CompletedComposedRun, + type ComposedRunRecord, +} from "../compose/index.js"; +import { + bindProjectViewerExtensions, + loadProjectViewerExtensions, +} from "../viewer-extension/projectDeclaration.js"; +import type { LinkedComposedBootstrap } from "./composedRunBootstrap.js"; +import { linkedComposedViewerManifestFields } from "./composedViewerBinding.js"; + +const sha = (bytes: Uint8Array): string => + `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +const correlatedBytes = async ( + root: string, + source: string, + expectedDigest: string, + expectedBytes: number, +): Promise => { + const bytes = await readFile(path.join(root, source)); + if (bytes.byteLength !== expectedBytes || sha(bytes) !== expectedDigest) { + throw new TypeError(`exported evidence changed before record assembly: ${source}`); + } + return bytes; +}; + +export const createLinkedComposedRecord = async ( + bootstrap: LinkedComposedBootstrap, +): Promise => { + const projectExtensions = await loadProjectViewerExtensions( + path.join(bootstrap.trusted_project_root, "Simfile"), + ); + const viewer = linkedComposedViewerManifestFields( + bootstrap.preparation, + projectExtensions, + ); + const record = await createComposedRunRecord({ + identity: { + contract_versions: { + "simfile.composed-command-receipt.v1": "simfile.composed-command-receipt.v1", + "simfile.composed-phase-journal.v1": "simfile.composed-phase-journal.v1", + "simfile.composed-terminal-receipt.v1": "simfile.composed-terminal-receipt.v1", + "simfile.composed-replay-receipt.v1": "simfile.composed-replay-receipt.v1", + ...(viewer === undefined ? {} : { + "simfile.composed-viewer-binding.v1": "simfile.composed-viewer-binding.v1", + }), + }, + created_at: new Date().toISOString(), + run_id: bootstrap.run_id, + spawnfile: { fingerprint: bootstrap.compile_fingerprint }, + world: { + artifact_manifest_digest: bootstrap.request.world.artifact_manifest_digest, + bundle_digest: bootstrap.request.world.bundle_digest, + runtime_abi: bootstrap.request.world.runtime_abi, + ...(viewer ?? {}), + }, + }, + out_dir: bootstrap.run_path, + }); + try { + const extensions = await bindProjectViewerExtensions(projectExtensions); + await record.writeArtifact({ bytes: extensions, + path: "viewer-extensions.json", role: "presentation" }); + return record; + } catch (error) { + await record.abort(); + throw error; + } +}; + +/** Reconciles both owners' preserved exports before atomically sealing the record. */ +export const sealLinkedComposedRecord = async (input: Readonly<{ + bootstrap: LinkedComposedBootstrap; + lifecycle: CompletedComposedRun; + record: ComposedRunRecord; +}>): Promise>> => { + const world = parseComposedWorldEvidenceReceipt( + composedPhasePayload(input.lifecycle.journal, "world_evidence_exported").evidence, + ); + const worldInventory = new Map(world.inventory.map((item) => [item.path, item])); + for (const artifact of input.bootstrap.preparation.evidence_artifacts) { + const exported = worldInventory.get(artifact.source); + if (exported === undefined) { + throw new TypeError(`world evidence mapping is absent: ${artifact.source}`); + } + await input.record.writeArtifact({ + bytes: await correlatedBytes(input.bootstrap.world_evidence_directory, + artifact.source, exported.sha256, exported.bytes), + path: artifact.path, role: artifact.role, + }); + } + const organization = parseComposedOrganizationEvidenceReceipt( + composedPhasePayload(input.lifecycle.journal, "organization_evidence_exported").evidence, + ); + for (const artifact of organization.files) { + await input.record.writeArtifact({ + bytes: await correlatedBytes(input.bootstrap.organization_evidence_directory, + artifact.path, artifact.sha256, artifact.bytes), + path: path.posix.join("organization", artifact.path), + role: "authority-export", + }); + } + const exportIndex = await readFile(path.join( + input.bootstrap.organization_evidence_directory, "spawnfile", "export-index.json", + )); + await input.record.writeArtifact({ bytes: exportIndex, + path: "organization/spawnfile/export-index.json", role: "authority-export" }); + await input.record.writeArtifact({ + bytes: new TextEncoder().encode(`${canonicalComposedJson(input.lifecycle.journal)}\n`), + path: "lifecycle/phase-journal.json", role: "provenance", + }); + await input.record.writeArtifact({ + bytes: new TextEncoder().encode(`${canonicalComposedJson(input.lifecycle.receipt)}\n`), + path: "lifecycle/terminal-receipt.json", role: "terminal", + }); + await input.record.writeArtifact({ + bytes: await readFile(path.join(input.bootstrap.execution.provider.compiled_output_directory, + "spawnfile-report.json")), + path: "organization/spawnfile-report.json", role: "provenance", + }); + return input.record.seal(); +}; diff --git a/src/cli/composedRunBootstrap.test.ts b/src/cli/composedRunBootstrap.test.ts new file mode 100644 index 0000000..ac29f68 --- /dev/null +++ b/src/cli/composedRunBootstrap.test.ts @@ -0,0 +1,78 @@ +import assert from "node:assert/strict"; +import { access, mkdir, mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import type { Simfile } from "../schema/index.js"; +import { composedOrganizationExportLifecycleInvocationId } from + "../compose/finalize-organization.js"; +import { + composedDeploymentName, + composedHandoffRunEnvironment, + composedProviderLifecycleInvocations, + composedOrganizationContainerName, + composedOrganizationUnitId, + prepareLinkedComposedRun, +} from "./composedRunBootstrap.js"; +import type { ParsedRunOptions } from "./runArguments.js"; + +const simfile = { clock: { seed: "neutral-seed" } } as Simfile; +const options = (root: string, overrides: Partial = {}): ParsedRunOptions => ({ + local: false, outDir: path.join(root, "run"), path: path.join(root, "Simfile"), + view: false, ...overrides, +}); + +test("composed organization container names are deterministic DNS labels", () => { + const name = composedOrganizationContainerName("run-one"); + assert.match(name, /^simfile-org-[a-f0-9]{16}$/u); + assert.equal(name, composedOrganizationContainerName("run-one")); +}); + +test("composed deployment names are deterministic Spawnfile identifiers", () => { + const name = composedDeploymentName("run-one"); + assert.match(name, /^simfile-[a-f0-9]{16}$/u); + assert.equal(name, composedDeploymentName("run-one")); + assert.doesNotMatch(name, /_/u); + assert.equal(composedOrganizationUnitId("run-one"), `${name}-container`); +}); + +test("composed handoff environment correlates the exact authorized run identity", () => { + assert.deepEqual(composedHandoffRunEnvironment("run-one"), { + NOOPOLIS_RUN_ID: "run-one", + }); + assert.throws(() => composedHandoffRunEnvironment("invalid run id")); +}); + +test("composed bootstrap persists the finalizer's exact export lifecycle identity", () => { + const requestDigest = `sha256:${"a".repeat(64)}`; + const invocations = composedProviderLifecycleInvocations("run-one", requestDigest); + assert.equal(invocations.export, + composedOrganizationExportLifecycleInvocationId(requestDigest)); + assert.notEqual(invocations.export, invocations.up); + assert.notEqual(invocations.export, invocations.down); +}); + +test("composed bootstrap rejects occupied output before creating support state", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-bootstrap-preflight-")); + try { + await mkdir(path.join(root, "run")); + await assert.rejects(prepareLinkedComposedRun({ + linked_spawnfile_path: path.join(root, "Spawnfile"), options: options(root), + simfile, simfile_path: path.join(root, "Simfile"), source_text: "source", + }), /output path already exists/u); + await assert.rejects(access(path.join(root, ".simfile-composed"))); + } finally { await rm(root, { force: true, recursive: true }); } +}); + +test("composed bootstrap validates run identity before lifecycle prerequisites", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-bootstrap-identity-")); + try { + await assert.rejects(prepareLinkedComposedRun({ + linked_spawnfile_path: path.join(root, "Spawnfile"), + options: options(root, { runId: "invalid run id" }), simfile, + simfile_path: path.join(root, "Simfile"), source_text: "source", + })); + await assert.rejects(access(path.join(root, ".simfile-composed"))); + } finally { await rm(root, { force: true, recursive: true }); } +}); diff --git a/src/cli/composedRunBootstrap.ts b/src/cli/composedRunBootstrap.ts new file mode 100644 index 0000000..6ddfe4a --- /dev/null +++ b/src/cli/composedRunBootstrap.ts @@ -0,0 +1,399 @@ +import { createHash } from "node:crypto"; +import { constants } from "node:fs"; +import { access, lstat, mkdir, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { pathToFileURL } from "node:url"; + +import { z } from "zod"; + +import { + composedOrganizationExportLifecycleInvocationId, + composedRunIdSchema, + createComposedRunRequestDigest, + parseComposedExecution, + parseComposedRunRequest, + type ComposedExecution, + type ComposedProjectBinding, + type ComposedProjectPreparation, + type ComposedRunRequest, +} from "../compose/index.js"; +import { canonicalComposedJson, digestComposedJson } from "../compose/json.js"; +import type { Simfile } from "../schema/index.js"; +import { + runSpawnfileCompile, + runSpawnfileDeriveBundlePolicy, + runSpawnfilePrepareContainerBundle, + runSpawnfileProvisionCredentials, + runSpawnfileRevokeCredentialSource, + runSpawnfileSelectTarget, + type SpawnfileCredentialProvisioningReceipt, + type SpawnfileSelectedTarget, +} from "../spawnfile/bootstrapCli.js"; +import { runSpawnfileConfigProducer } from "../spawnfile/process.js"; +import { RUNNABLE_WORLD_SIDECAR_ARCHIVE_PATHS } from + "../world-artifact/index.js"; +import { + compileReportMemberEngines, + compileReportMoltnetReleaseExpectation, + deriveCompiledOrganizationArtifactDigest, + parseSpawnfileCompileReport, +} from "./compiledOrganizationIdentity.js"; +import { projectCredentialBindingNames } from "./credentialBindingProjection.js"; +import type { ParsedRunOptions } from "./runArguments.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const identifier = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); + +const sha = (bytes: Uint8Array | string): `sha256:${string}` => + `sha256:${createHash("sha256").update(bytes).digest("hex")}`; +const key = (domain: string, value: unknown): string => + digestComposedJson(domain, value).slice(7, 39); +export const composedOrganizationContainerName = (runId: string): string => + `simfile-org-${key("simfile.composed-container.v1", runId).slice(0, 16)}`; +export const composedDeploymentName = (runId: string): string => + `simfile-${key("simfile.composed-deployment.v1", runId).slice(0, 16)}`; +export const composedOrganizationUnitId = (runId: string): string => + `${composedDeploymentName(runId)}-container`; +export const composedHandoffRunEnvironment = (runId: string): Readonly> => + Object.freeze({ NOOPOLIS_RUN_ID: composedRunIdSchema.parse(runId) }); +export const composedProviderLifecycleInvocations = (runId: string, requestDigest: string) => + Object.freeze({ + down: `lci_${key("simfile.composed-lifecycle.down.v1", runId)}`, + export: composedOrganizationExportLifecycleInvocationId(requestDigest), + up: `lci_${key("simfile.composed-lifecycle.up.v1", runId)}`, + }); +const environmentValue = (name: string): string | undefined => { + const value = process.env[name]; + return value === undefined || value.length === 0 ? undefined : value; +}; +const executable = async (explicit?: string): Promise => { + const candidates = explicit === undefined + ? (process.env.PATH ?? "").split(path.delimiter).filter(Boolean) + .map((directory) => path.resolve(directory, "spawnfile")) + : [path.resolve(explicit)]; + for (const candidate of candidates) { + try { await access(candidate, constants.X_OK); return candidate; } catch { /* continue */ } + } + throw new TypeError("Spawnfile executable is unavailable"); +}; +const writePrivateJson = async (target: string, value: unknown): Promise => { + await writeFile(target, canonicalComposedJson(value), { flag: "wx", mode: 0o600 }); +}; +const targetConfig = async (input: Readonly<{ + command: string; + environment: NodeJS.ProcessEnv; + selector: string; + signal?: AbortSignal; + workdir: string; +}>): Promise => runSpawnfileConfigProducer({ + args: [input.selector], command: input.command, cwd: input.workdir, + env: input.environment, signal: input.signal, +}); +const loadBinding = async (simfilePath: string, simfile: Simfile): Promise => { + const reference = simfile.world_sidecar?.binding; + if (reference === undefined) throw new TypeError("linked composed project has no binding"); + const modulePath = path.resolve(path.dirname(simfilePath), reference); + const loaded = await import(pathToFileURL(modulePath).href) as Record; + const binding = loaded.composedProjectBinding as ComposedProjectBinding | undefined; + if (binding?.version !== "simfile.composed-project-binding.v1" + || typeof binding.prepareComposedProject !== "function") { + throw new TypeError("linked composed project binding is invalid"); + } + return binding; +}; +export interface LinkedComposedBootstrap { + readonly auth: SpawnfileCredentialProvisioningReceipt; + readonly compile_fingerprint: string; + readonly execution: ComposedExecution; + readonly journal_path: string; + readonly organization_evidence_directory: string; + readonly preparation: ComposedProjectPreparation; + readonly request: ComposedRunRequest; + readonly run_id: string; + readonly run_path: string; + readonly source_handles: readonly string[]; + readonly support_root: string; + readonly trusted_project_root: string; + readonly world_evidence_directory: string; +} + +const revokeCredentialSources = async ( + cli: Parameters[0], + sources: readonly string[], +): Promise => { + const failures: unknown[] = []; + for (const source_handle of sources) { + try { await runSpawnfileRevokeCredentialSource(cli, { source_handle }); } + catch (error) { failures.push(error); } + } + if (failures.length > 0) throw new AggregateError( + failures, "composed credential source revocation is incomplete", + ); +}; + +export const revokeLinkedComposedSources = async ( + bootstrap: LinkedComposedBootstrap, +): Promise => revokeCredentialSources({ + cwd: bootstrap.execution.provider.spawnfile_cwd, + env: bootstrap.execution.provider.process_environment === undefined + ? process.env : { ...process.env, ...bootstrap.execution.provider.process_environment }, + spawnfileBin: bootstrap.execution.provider.spawnfile_bin, +}, bootstrap.source_handles); + +/** Resolves operator inputs and prepares only durable prerequisites through public Spawnfile CLIs. */ +export const prepareLinkedComposedRun = async (input: Readonly<{ + linked_spawnfile_path: string; + options: ParsedRunOptions; + signal?: AbortSignal; + simfile: Simfile; + simfile_path: string; + source_text: string; +}>): Promise => { + const simfilePath = path.resolve(input.simfile_path); + const spawnfilePath = path.resolve(input.linked_spawnfile_path); + const seed = z.string().min(1).max(4_096).parse( + input.options.seed ?? input.simfile.clock.seed, + ); + const runId = composedRunIdSchema.parse(input.options.runId + ?? (seed.replace(/[^A-Za-z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "") || "run")); + const runPath = path.resolve(input.options.outDir ?? `runs/${runId}`); + try { + await lstat(runPath); + throw new TypeError("composed output path already exists"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + const supportRoot = path.resolve(environmentValue("SIMFILE_COMPOSED_SUPPORT_ROOT") + ?? path.join(path.dirname(runPath), ".simfile-composed", runId)); + await mkdir(path.dirname(supportRoot), { recursive: true, mode: 0o700 }); + await mkdir(supportRoot, { mode: 0o700 }); + const directories = Object.freeze({ + auth: path.join(supportRoot, "auth"), compiled: path.join(supportRoot, "compiled"), + organizationEvidence: path.join(supportRoot, "evidence", "organization"), + worldEvidenceArchive: path.join(supportRoot, "evidence", "world.tar"), + worldEvidence: path.join(supportRoot, "evidence", "world"), + }); + await Promise.all([mkdir(directories.auth, { mode: 0o700 }), + mkdir(path.dirname(directories.organizationEvidence), { recursive: true, mode: 0o700 })]); + const architectureInput = environmentValue("SPAWNFILE_MOLTNET_TARGET_ARCH") ?? "amd64"; + if (architectureInput !== "amd64" && architectureInput !== "arm64") { + throw new TypeError("Spawnfile target architecture is invalid"); + } + const architecture: "amd64" | "arm64" = architectureInput; + const platform = { architecture, os: "linux" as const }; + const baseImageConfigDigest = digest.parse( + environmentValue("SIMFILE_WORLD_BASE_IMAGE_CONFIG_DIGEST"), + ); + const selector = identifier.parse(environmentValue("SPAWNFILE_TARGET_SELECTOR") ?? "gpu-4090"); + const authProfile = identifier.parse(environmentValue("SPAWNFILE_AUTH_PROFILE") ?? "simfile-live"); + const producer = environmentValue("SPAWNFILE_TARGET_CONFIG_PRODUCER") + ?? "target-config-producer"; + const spawnfileBin = await executable(environmentValue("SPAWNFILE_BIN")); + const projectRoot = path.dirname(simfilePath); + const targetPlanPath = path.join(supportRoot, "target-plan.json"); + const processEnvironment: Record = { + ...composedHandoffRunEnvironment(runId), + SIMFILE_COMPOSED_TARGET_PLAN: targetPlanPath, + SPAWNFILE_HOME: directories.auth, + SPAWNFILE_MOLTNET_TARGET_ARCH: architecture, + }; + const releaseDirectory = environmentValue("SPAWNFILE_MOLTNET_RELEASE_DIR"); + if (releaseDirectory !== undefined) { + processEnvironment.SPAWNFILE_MOLTNET_RELEASE_DIR = path.resolve(releaseDirectory); + } + const environment = { ...process.env, ...processEnvironment }; + const cli = { cwd: projectRoot, env: environment, spawnfileBin }; + const organizationContainer = composedOrganizationContainerName(runId); + const binding = await loadBinding(simfilePath, input.simfile); + const preparation = await binding.prepareComposedProject({ + base_image_config_digest: baseImageConfigDigest, + evidence_root: "/var/lib/simfile/evidence", internal_port: 4070, + organization_container_name: organizationContainer, platform, run_id: runId, + secret_root: "/run/spawnfile-secrets", seed, simfile_path: simfilePath, + spawnfile_path: spawnfilePath, + }); + const bundle = preparation.bundle; + const credentialProjection = projectCredentialBindingNames(preparation); + const claims = { + archiveDigest: bundle.archive_sha256, + artifactDigest: bundle.manifest.artifact.service_digest, + baseImageConfigDigest, bundleDigest: bundle.manifest.digest, + entrypoint: bundle.manifest.entrypoint, + launcherDigest: bundle.manifest.launcher.sha256, + networkAlias: bundle.manifest.network.dns_alias, platform, + }; + const policy = await runSpawnfileDeriveBundlePolicy(cli, claims, input.signal); + let config = await targetConfig({ command: producer, environment, selector, + signal: input.signal, workdir: projectRoot }); + let selected: SpawnfileSelectedTarget; + try { + selected = await runSpawnfileSelectTarget(cli, { request: { + idempotency_key: `idem_${key("simfile.composed-select-target.v1", { runId, selector })}`, + operation: "select_target", target_reference: selector, + version: "spawnfile.target-resource.request.v1", + }, signal: input.signal, target_config_stdin: config }); + } finally { config.fill(0); } + await writePrivateJson(targetPlanPath, { + evidence_destination: directories.worldEvidenceArchive, + prepared_artifact_mapping: { + archive_digest: bundle.archive_sha256, + artifact_manifest_digest: bundle.manifest.artifact.service_digest, + base_image_config_digest: baseImageConfigDigest, + build_policy_digest: policy.build_policy_digest, + bundle_digest: bundle.manifest.digest, entrypoint: bundle.manifest.entrypoint, + launcher_digest: bundle.manifest.launcher.sha256, + network_alias: bundle.manifest.network.dns_alias, platform, + platform_digest: policy.platform_digest, + }, + run_id: runId, target_selector: selector, + version: "simfile.composed-target-plan.v1", + }); + const bundleRequest = { + archive_base64: Buffer.from(bundle.archive_bytes).toString("base64"), + archive_digest: bundle.archive_sha256, + archive_entries: RUNNABLE_WORLD_SIDECAR_ARCHIVE_PATHS, + artifact_digest: bundle.manifest.artifact.service_digest, + build_policy_digest: policy.build_policy_digest, + bundle_digest: bundle.manifest.digest, entrypoint: bundle.manifest.entrypoint, + idempotency_key: `idem_${key("simfile.composed-prepare-bundle.v1", { + archive: bundle.archive_sha256, selected, + })}`, + launcher_digest: bundle.manifest.launcher.sha256, + network_alias: bundle.manifest.network.dns_alias, platform, + platform_digest: policy.platform_digest, + selected_target: { fingerprint: selected.fingerprint, handle: selected.handle }, + version: "spawnfile.target-local-container-bundle.prepare-request.v1", + } as const; + config = await targetConfig({ command: producer, environment, selector, + signal: input.signal, workdir: projectRoot }); + try { + await runSpawnfilePrepareContainerBundle(cli, { request: bundleRequest, + signal: input.signal, target_config_stdin: config }); + } finally { config.fill(0); } + const report = parseSpawnfileCompileReport(await runSpawnfileCompile(cli, { + compiled_output_directory: directories.compiled, + organization_path: spawnfilePath, signal: input.signal, + })); + if (report.container.moltnet.release.architecture !== architecture) { + throw new TypeError("Spawnfile Moltnet release architecture drift"); + } + const selectedReceipt = { ...selected, + version: "spawnfile.target-resource.selected-target.v1" as const }; + const selectedFile = path.join(supportRoot, "selected-target.json"); + await writePrivateJson(selectedFile, selectedReceipt); + const selectedDigest = sha(canonicalComposedJson(selectedReceipt)); + const organizationArtifactDigest = deriveCompiledOrganizationArtifactDigest( + report.compile_fingerprint, + ); + const simfileDigest = sha(input.source_text); + const spawnfileDigest = sha(await readFile(spawnfilePath)); + const descriptorDigest = digestComposedJson("simfile.composed-project-descriptor.v1", { + organization: report.compile_fingerprint, simfile: simfileDigest, + spawnfile: spawnfileDigest, world: bundle.manifest.digest, + }); + const envFile = path.join(supportRoot, "organization.env"); + const bindingsFile = path.join(supportRoot, "world-bindings.json"); + const grantsFile = path.join(supportRoot, "resolved-world-grants.json"); + await writePrivateJson(grantsFile, { + grants: preparation.world_members.map((member) => ({ + capability_manifest: member.capability_manifest, + member_id: member.id, principal_id: member.principal_id, + })), + run_id: runId, version: "spawnfile.auth.resolved-world-grants.v1", + world_instance_id: preparation.readiness_expectation.world_instance_id, + }); + const auth = await runSpawnfileProvisionCredentials(cli, { + env_file: envFile, + request: { + credentials: credentialProjection.credentials, descriptor_digest: descriptorDigest, + model_engine_auth: { kind: "codex", profile: authProfile }, + run_id: runId, scope: "world", selected_target: selectedReceipt, + version: "spawnfile.auth.credential-provisioning.request.v1", + world_bindings: { + json_url: `http://${bundle.manifest.network.dns_alias}:${bundle.manifest.network.internal_port}/v1/world`, + mcp_url: `http://${bundle.manifest.network.dns_alias}:${bundle.manifest.network.internal_port}/mcp`, + members: credentialProjection.world_members.map(({ id, principal_id, token_credential_name }) => + ({ id, principal_id, token_credential_name })), + world_instance_id: preparation.readiness_expectation.world_instance_id, + }, + }, + resolved_grants_file: grantsFile, signal: input.signal, + world_bindings_file: bindingsFile, + }); + const sourceHandles = Object.freeze(auth.credentials.map(({ source_handle }) => source_handle)); + try { + const worldBindingsDigest = digest.parse(auth.world_bindings_digest); + const sourceByName = new Map(auth.credentials.map(({ name, source_handle }) => + [name, source_handle])); + const request = parseComposedRunRequest({ + descriptor_digest: descriptorDigest, mode: "live", + organization: { artifact_digest: organizationArtifactDigest, + source_digest: spawnfileDigest, world_bindings_digest: worldBindingsDigest }, + required_world_capabilities: ["simfile.world-decision-claim.v1"], + run_id: runId, source_digest: simfileDigest, + target: { auth_profile: authProfile, selector }, + version: "simfile.composed-run-request.v1", + world: { artifact_manifest_digest: bundle.manifest.artifact.service_digest, + bundle_digest: bundle.manifest.digest, + runtime_abi: "simfile.world-sidecar-runtime.v1" }, + }); + const requestDigest = createComposedRunRequestDigest(request); + const execution = parseComposedExecution({ + configuration: { + organization_expectation: { + deployment_name: composedDeploymentName(runId), + member_engines: compileReportMemberEngines(report), + moltnet_release: compileReportMoltnetReleaseExpectation(report), + selected_target_receipt_digest: selectedDigest, + unit_id: composedOrganizationUnitId(runId), + world_binding_digest: worldBindingsDigest, + }, + readiness_expectation: preparation.readiness_expectation, + terminal_tick: preparation.terminal_tick, + topology_expectation: { selected_target: { + fingerprint: selected.fingerprint, handle: selected.handle, + } }, + }, + provider: { + compiled_output_directory: directories.compiled, + evidence_destination_directory: directories.organizationEvidence, + evidence_mount_path: "/var/lib/simfile/evidence", + lifecycle_invocations: composedProviderLifecycleInvocations(runId, requestDigest), + organization_handoff: { env_file: envFile, + selected_target_receipt_file: selectedFile, world_bindings_file: bindingsFile }, + organization_container_name: organizationContainer, + organization_image_tag: `simfile-org-${key("simfile.composed-image.v1", runId).slice(0, 16)}:run`, + organization_path: spawnfilePath, process_environment: processEnvironment, + spawnfile_bin: spawnfileBin, spawnfile_cwd: projectRoot, + target_config_producer: { args: [selector], command: producer, + transport: "stdout_to_spawnfile_stdin" }, + terminal_artifact: { id: "composed_terminal", max_bytes: 131_072, + path: "/tmp/spawnfile-public/composed-terminal.json" }, + world_evidence_export: { + archive_path: directories.worldEvidenceArchive, + destination_directory: directories.worldEvidence, + }, + world_readiness_port: bundle.manifest.network.internal_port, + }, + secret_bindings: credentialProjection.secret_bindings.map((binding) => ({ + name: binding.name, scope: binding.scope, + source_handle: z.string().parse(sourceByName.get(binding.credential_name)), + })), + version: "simfile.composed-execution.v1", + }); + return Object.freeze({ auth, compile_fingerprint: report.compile_fingerprint, execution, + journal_path: path.join(supportRoot, "journal", "phase-journal.json"), + organization_evidence_directory: directories.organizationEvidence, + preparation, request, run_id: runId, run_path: runPath, + source_handles: sourceHandles, support_root: supportRoot, + trusted_project_root: projectRoot, + world_evidence_directory: directories.worldEvidence }); + } catch (error) { + try { await revokeCredentialSources(cli, sourceHandles); } + catch (revocationError) { + throw new AggregateError([error, revocationError], + "composed bootstrap failed and credential revocation is incomplete"); + } + throw error; + } +}; diff --git a/src/cli/composedRunCommand.ts b/src/cli/composedRunCommand.ts new file mode 100644 index 0000000..7fa72a0 --- /dev/null +++ b/src/cli/composedRunCommand.ts @@ -0,0 +1,202 @@ +import { + assertComposedDecisionInputs, + attachComposedViewer, + composedCommandExitCode, + composedRunConfiguration, + createComposedCommandReceipt, + createComposedJournalSession, + createComposedLiveViewerProjection, + createComposedPhaseJournal, + deriveComposedLiveEvidence, + replayComposedRunRecord, + runPreflightedComposedRun, + serializeComposedReceipt, + writeComposedFinalReceipt, + writeComposedProgress, + type CompletedComposedRun, + type ComposedViewerAttachment, + type ComposedLiveViewerProjection, +} from "../compose/index.js"; +import type { Simfile } from "../schema/index.js"; +import { createProductionComposedRunPorts } from "../spawnfile/productionPorts.js"; +import { + bindTerminalViewerProjection, + startProductionViewerProjection, + type ProductionViewerProjectionObserver, +} from "../spawnfile/productionViewerProjection.js"; +import { prepareLinkedComposedRun, revokeLinkedComposedSources } from + "./composedRunBootstrap.js"; +import { createLinkedComposedRecord, sealLinkedComposedRecord } from + "./composedRunArtifacts.js"; +import type { ParsedRunOptions } from "./runArguments.js"; + +export interface LinkedComposedRunInput { + readonly linked_spawnfile_path: string; + readonly options: ParsedRunOptions; + readonly simfile: Simfile; + readonly simfile_path: string; + readonly source_text: string; +} + +export type LinkedComposedRunCommand = ( + input: LinkedComposedRunInput, +) => Promise; + +const recoveryExitCode = (signal: "SIGINT" | "SIGTERM" | "failure" | "restart"): number => + signal === "SIGINT" ? 130 : signal === "SIGTERM" ? 143 : 1; +const receiptViewer = ( + attachment: ComposedViewerAttachment | undefined, + projectionError?: string, +) => { + if (attachment === undefined) return { state: "disabled" as const }; + if (projectionError !== undefined) { + return { error: projectionError, state: "unavailable" as const }; + } + if (attachment.state === "attached") { + return { state: "attached" as const, url: attachment.url }; + } + return { error: attachment.error, state: "unavailable" as const }; +}; + +/** Owns the one production route into the generic composed lifecycle. */ +export const runLinkedComposedCommand: LinkedComposedRunCommand = async (input) => { + assertComposedDecisionInputs({ simfile: input.simfile }); + writeComposedProgress("Preparing linked composed run"); + const bootstrap = await prepareLinkedComposedRun(input); + let record; + try { + record = await createLinkedComposedRecord(bootstrap); + } catch (error) { + await revokeLinkedComposedSources(bootstrap); + throw error; + } + const initial = createComposedPhaseJournal( + bootstrap.request, new Date().toISOString(), bootstrap.execution, + ); + const session = await createComposedJournalSession(bootstrap.journal_path, initial); + let viewer: ComposedViewerAttachment | undefined; + let projection: ComposedLiveViewerProjection | undefined; + let projectionObserver: ProductionViewerProjectionObserver | undefined; + let projectionError: string | undefined; + if (input.options.view) { + viewer = await attachComposedViewer({ run_dir: record.out_dir, + trusted_project_root: bootstrap.trusted_project_root }); + if (viewer.state === "attached") { + writeComposedProgress(`Viewer: ${viewer.url}`); + if (bootstrap.preparation.viewer?.live_trace !== undefined) { + try { + projection = createComposedLiveViewerProjection({ + binding: bootstrap.preparation.viewer, + run_id: bootstrap.run_id, + staging_dir: record.staging_dir, + }); + projectionObserver = startProductionViewerProjection({ + binding: bootstrap.preparation.viewer, + execution: bootstrap.execution, + journal_session: session, + publish: projection.publish, + }); + } catch (error) { + projectionError = error instanceof Error ? error.message : String(error); + writeComposedProgress(`Viewer projection unavailable: ${projectionError}`); + } + } + } + else writeComposedProgress(`Viewer unavailable: ${viewer.error}`); + } + writeComposedProgress("Starting world-first composed lifecycle"); + let outcome; + try { + const ports = createProductionComposedRunPorts({ + execution: bootstrap.execution, journal_session: session, + }); + outcome = await runPreflightedComposedRun({ + configuration: composedRunConfiguration(bootstrap.execution), + decision_inputs: { simfile: input.simfile }, + journal_path: bootstrap.journal_path, + journal_session: session, + ports: projectionObserver === undefined ? ports : { + ...ports, + world_finalization: bindTerminalViewerProjection( + ports.world_finalization, projectionObserver, + ), + }, + request: bootstrap.request, + }); + } catch (error) { + await projectionObserver?.close(); + await record.abort(); + if (viewer?.state === "attached") await viewer.close(); + await revokeLinkedComposedSources(bootstrap); + throw error; + } + const projectionObservation = await projectionObserver?.close(); + if (projectionObservation !== undefined) { + writeComposedProgress(`Viewer projection: ${projectionObservation.published_snapshots} ` + + `authenticated snapshot(s), ${projectionObservation.failed_snapshots} rejected`); + if (projectionObservation.published_snapshots === 0) { + projectionError = projectionObservation.last_error + ?? "no authenticated viewer projection was captured"; + } + } + if (outcome.receipt.status === "recovery_required") { + await record.abort(); + if (viewer?.state === "attached") await viewer.close(); + process.stdout.write(serializeComposedReceipt(outcome.receipt)); + return recoveryExitCode(outcome.receipt.signal); + } + const completed = outcome as CompletedComposedRun; + let revocationAttempted = false; + try { + writeComposedProgress("Reconciling and sealing exported evidence"); + if (projection !== undefined) { + try { + const captured = await projection.finalize(record); + if (captured.publications === 0) { + projectionError ??= "no authenticated viewer projection was captured"; + } + } catch (error) { + projectionError = error instanceof Error ? error.message : String(error); + writeComposedProgress(`Viewer projection evidence unavailable: ${projectionError}`); + } + } + const sealed = await sealLinkedComposedRecord({ bootstrap, + lifecycle: completed, record }); + if (viewer?.state === "attached") { + try { + const seal = await viewer.awaitSeal(); + if (seal.status === "failed") { + projectionError ??= seal.error ?? "viewer seal reconciliation failed"; + } + } catch (error) { + projectionError ??= error instanceof Error ? error.message : String(error); + } + } + const replay = await replayComposedRunRecord({ + adapter: bootstrap.preparation.replay_adapter, + run_dir: sealed.out_dir, + }); + writeComposedProgress(`Exact replay verified at tick ${replay.terminal_tick}`); + const liveEvidence = await deriveComposedLiveEvidence({ + accepted_actions_path: "actions/accepted.json", + principals_path: "identity/principals.json", + run_dir: sealed.out_dir, + }); + revocationAttempted = true; + await revokeLinkedComposedSources(bootstrap); + const receipt = createComposedCommandReceipt({ + journal: completed.journal, lifecycle_receipt: completed.receipt, + live_evidence: liveEvidence, + manifest_digest: `sha256:${sealed.manifest_sha256}`, + run_path: sealed.out_dir, viewer: receiptViewer(viewer, projectionError), + }); + writeComposedFinalReceipt(receipt); + return composedCommandExitCode(receipt); + } finally { + try { + if (!revocationAttempted) await revokeLinkedComposedSources(bootstrap); + } finally { + if (viewer?.state === "attached") await viewer.close(); + } + } +}; diff --git a/src/cli/composedViewerBinding.test.ts b/src/cli/composedViewerBinding.test.ts new file mode 100644 index 0000000..b1fb674 --- /dev/null +++ b/src/cli/composedViewerBinding.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { ComposedProjectPreparation } from "../compose/projectBinding.js"; +import type { LoadedProjectViewerExtensions } from + "../viewer-extension/projectDeclaration.js"; +import { linkedComposedViewerManifestFields } from "./composedViewerBinding.js"; + +const preparation = (id = "fixture-renderer") => ({ + viewer: { + extensions: [{ id, recorded_artifact: "presentation/world.json" }], + live_trace: { + artifact: { id: "viewer_trace", max_bytes: 120_000, + media_type: "application/json", path: "/tmp/spawnfile-public/viewer.json" }, + extension_id: id, + }, + version: "simfile.composed-viewer-binding.v1", + }, +}) as unknown as ComposedProjectPreparation; +const project = { + declaration: { + extensions: [{ descriptor: "./dist/renderer.json", id: "fixture-renderer" }], + version: "simfile.project-viewer-extensions.v1", + }, +} as unknown as LoadedProjectViewerExtensions; + +describe("linked composed viewer manifest fields", () => { + it("maps one trusted extension to one recorded trace", () => { + assert.deepEqual(linkedComposedViewerManifestFields(preparation(), project), { + viewer_extension_data: { "fixture-renderer": "presentation/world.json" }, + viewer_projection: "presentation/world.json", + }); + assert.equal(linkedComposedViewerManifestFields( + {} as ComposedProjectPreparation, project, + ), undefined); + }); + + it("rejects an extension id absent from the trusted project declaration", () => { + assert.throws( + () => linkedComposedViewerManifestFields(preparation("other-renderer"), project), + /undeclared extension/u, + ); + }); +}); diff --git a/src/cli/composedViewerBinding.ts b/src/cli/composedViewerBinding.ts new file mode 100644 index 0000000..45078ca --- /dev/null +++ b/src/cli/composedViewerBinding.ts @@ -0,0 +1,34 @@ +import type { ComposedProjectPreparation } from "../compose/projectBinding.js"; +import type { + LoadedProjectViewerExtensions, +} from "../viewer-extension/projectDeclaration.js"; + +export interface LinkedComposedViewerManifestFields { + readonly viewer_extension_data: Readonly>; + readonly viewer_projection?: string; +} + +/** + * Corroborates fixture-declared presentation data against the trusted local + * extension ids before any run directory is reserved. + */ +export const linkedComposedViewerManifestFields = ( + preparation: ComposedProjectPreparation, + project: LoadedProjectViewerExtensions, +): LinkedComposedViewerManifestFields | undefined => { + const viewer = preparation.viewer; + if (viewer === undefined) return undefined; + const trustedIds = new Set(project.declaration.extensions.map(({ id }) => id)); + if (viewer.extensions.some(({ id }) => !trustedIds.has(id))) { + throw new TypeError("composed viewer binding references an undeclared extension"); + } + const data = Object.freeze(Object.fromEntries(viewer.extensions.map(({ id, + recorded_artifact }) => [id, recorded_artifact]))); + const projection = viewer.live_trace === undefined + ? undefined + : data[viewer.live_trace.extension_id]; + return Object.freeze({ + viewer_extension_data: data, + ...(projection === undefined ? {} : { viewer_projection: projection }), + }); +}; diff --git a/src/cli/credentialBindingProjection.test.ts b/src/cli/credentialBindingProjection.test.ts new file mode 100644 index 0000000..095d5df --- /dev/null +++ b/src/cli/credentialBindingProjection.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { projectCredentialBindingNames } from "./credentialBindingProjection.js"; + +test("projects one credential alias across provisioning, bindings, and members", () => { + const projected = projectCredentialBindingNames({ + credentials: [ + { env: "BLUE_WORLD", kind: "generated-token", name: "blue_world" }, + { env: "ORG_TOKEN", kind: "generated-token", name: "org_token" }, + ], + secret_bindings: [ + { credential_name: "blue_world", name: "blue_bearer", scope: "world" }, + ], + world_members: [{ id: "blue", token_credential_name: "blue_world" }], + }); + assert.deepEqual(projected.credentials, [ + { env: "BLUE_WORLD", kind: "generated-token", name: "blue_bearer" }, + { env: "ORG_TOKEN", kind: "generated-token", name: "org_token" }, + ]); + assert.deepEqual(projected.secret_bindings, [ + { credential_name: "blue_bearer", name: "blue_bearer", scope: "world" }, + ]); + assert.deepEqual(projected.world_members, [ + { id: "blue", token_credential_name: "blue_bearer" }, + ]); +}); + +test("rejects duplicate source use and alias collisions before provisioning", () => { + const base = { + credentials: [{ name: "blue_world" }, { name: "blue_bearer" }], + world_members: [{ token_credential_name: "blue_world" }], + }; + assert.throws(() => projectCredentialBindingNames({ + ...base, + secret_bindings: [ + { credential_name: "blue_world", name: "blue_one", scope: "world" }, + { credential_name: "blue_world", name: "blue_two", scope: "world" }, + ], + }), /identity is invalid/u); + assert.throws(() => projectCredentialBindingNames({ + ...base, + secret_bindings: [ + { credential_name: "blue_world", name: "blue_bearer", scope: "world" }, + ], + }), /alias collides/u); +}); diff --git a/src/cli/credentialBindingProjection.ts b/src/cli/credentialBindingProjection.ts new file mode 100644 index 0000000..f81e7b6 --- /dev/null +++ b/src/cli/credentialBindingProjection.ts @@ -0,0 +1,60 @@ +interface NamedCredential { + readonly name: string; +} + +interface CredentialBinding { + readonly credential_name: string; + readonly name: string; + readonly scope: string; +} + +interface CredentialMember { + readonly token_credential_name: string; +} + +/** + * Projects logical credential names onto private mount names before provisioning, + * so Spawnfile grant authorization and target secret filenames share one identity. + */ +export const projectCredentialBindingNames = < + Credential extends NamedCredential, + Binding extends CredentialBinding, + Member extends CredentialMember, +>(input: Readonly<{ + credentials: readonly Credential[]; + secret_bindings: readonly Binding[]; + world_members: readonly Member[]; +}>): Readonly<{ + credentials: readonly Credential[]; + secret_bindings: readonly Binding[]; + world_members: readonly Member[]; +}> => { + const available = new Set(input.credentials.map(({ name }) => name)); + const aliases = new Map(); + for (const binding of input.secret_bindings) { + if (!available.has(binding.credential_name) + || aliases.has(binding.credential_name)) { + throw new TypeError("composed secret binding credential identity is invalid"); + } + aliases.set(binding.credential_name, binding.name); + } + const credentials = input.credentials.map((credential) => Object.freeze({ + ...credential, name: aliases.get(credential.name) ?? credential.name, + })); + if (new Set(credentials.map(({ name }) => name)).size !== credentials.length) { + throw new TypeError("composed secret binding credential alias collides"); + } + const worldMembers = input.world_members.map((member) => Object.freeze({ + ...member, + token_credential_name: aliases.get(member.token_credential_name) + ?? member.token_credential_name, + })); + const secretBindings = input.secret_bindings.map((binding) => Object.freeze({ + ...binding, credential_name: binding.name, + })); + return Object.freeze({ + credentials: Object.freeze(credentials), + secret_bindings: Object.freeze(secretBindings), + world_members: Object.freeze(worldMembers), + }); +}; diff --git a/src/cli/index.test-helper.ts b/src/cli/index.test-helper.ts new file mode 100644 index 0000000..3d5a5c8 --- /dev/null +++ b/src/cli/index.test-helper.ts @@ -0,0 +1,79 @@ +import { scanMarkers } from "../ledger/markers.js"; +import { parseCanonicalLedgerJsonl } from "../ledger/validation.js"; +import { parseSimfileSource } from "../schema/parse.js"; + +export const captureStdout = async ( + operation: () => Promise, +): Promise<{ output: string; result: T }> => { + const chunks: string[] = []; + const original = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array) => { + chunks.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + const result = await operation(); + return { output: chunks.join(""), result }; + } finally { + process.stdout.write = original; + } +}; + +export const cliTranscriptSimfileSource = ` +simfile_version: "0.1" +name: cli-run-world +clock: + seed: cli-test + tick: 1m +variables: + pressure: + scope: room:office-floor:case-warroom + initial: 0.8 + range: 0..1 +generators: + ramp: + kind: deterministic + variable: pressure + delta: 0.1 +rules: + deadline: + when: + variable: pressure + above: 0.85 + do: + - action: moltnet:message + to: room:office-floor:case-warroom + content: "Rosa Delgado belongs here." + - action: moltnet:dm + to: agent:eleanor + content: "Private follow-up." + - action: moltnet:message + to: room:office-floor:case-warroom + content: "Observation notice." +markers: + tenant_name: + text: + - "Rosa Delgado" + mode: containment + scopes: + - room:office-floor:case-warroom +`; +const parsedCliTranscriptSimfile = parseSimfileSource( + cliTranscriptSimfileSource, + { path: "Simfile.yaml" }, +).simfile; + +export const markerIdsByEvent = (ledgerSource: string): Map => { + const events = parseCanonicalLedgerJsonl(ledgerSource, { runId: "smoke" }); + const hits = scanMarkers(events, parsedCliTranscriptSimfile.markers); + const markerIds = new Map(); + for (const markerHits of Object.values(hits)) { + for (const hit of markerHits) { + const existing = markerIds.get(hit.eventId) ?? []; + existing.push(hit.markerId); + markerIds.set(hit.eventId, existing); + } + } + for (const entry of markerIds.values()) entry.sort(); + return markerIds; +}; diff --git a/src/cli/index.test.ts b/src/cli/index.test.ts index bf3b85c..3d76b47 100644 --- a/src/cli/index.test.ts +++ b/src/cli/index.test.ts @@ -1,91 +1,37 @@ import assert from "node:assert/strict"; -import { mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { mkdtemp, readFile, stat, symlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { describe, it } from "node:test"; +import { pathToFileURL } from "node:url"; -import { scanMarkers } from "../ledger/markers.js"; import { parseCanonicalLedgerJsonl } from "../ledger/validation.js"; -import { parseSimfileSource } from "../schema/parse.js"; -import { runCli } from "./index.js"; +import { isCliEntrypoint, runCli } from "./index.js"; +import { + captureStdout, + cliTranscriptSimfileSource, + markerIdsByEvent, +} from "./index.test-helper.js"; -const captureStdout = async (operation: () => Promise): Promise<{ output: string; result: T }> => { - const chunks: string[] = []; - const original = process.stdout.write; - process.stdout.write = ((chunk: string | Uint8Array) => { - chunks.push(String(chunk)); - return true; - }) as typeof process.stdout.write; - - try { - const result = await operation(); - return { output: chunks.join(""), result }; - } finally { - process.stdout.write = original; - } -}; - -const cliTranscriptSimfileSource = ` -simfile_version: "0.1" -name: cli-run-world -clock: - seed: cli-test - tick: 1m -variables: - pressure: - scope: room:office-floor:case-warroom - initial: 0.8 - range: 0..1 -generators: - ramp: - kind: deterministic - variable: pressure - delta: 0.1 -rules: - deadline: - when: - variable: pressure - above: 0.85 - do: - - action: moltnet:message - to: room:office-floor:case-warroom - content: "Rosa Delgado belongs here." - - action: moltnet:dm - to: agent:eleanor - content: "Private follow-up." - - action: wake:recommend - to: room:office-floor:case-warroom -markers: - tenant_name: - text: - - "Rosa Delgado" - mode: containment - scopes: - - room:office-floor:case-warroom -`; -const parsedCliTranscriptSimfile = parseSimfileSource(cliTranscriptSimfileSource, { path: "Simfile.yaml" }).simfile; - -const markerIdsByEvent = (ledgerSource: string) => { - const events = parseCanonicalLedgerJsonl(ledgerSource, { runId: "smoke" }); - const hits = scanMarkers(events, parsedCliTranscriptSimfile.markers); - const markerIds = new Map(); - - for (const markerHits of Object.values(hits)) { - for (const hit of markerHits) { - const existing = markerIds.get(hit.eventId) ?? []; - existing.push(hit.markerId); - markerIds.set(hit.eventId, existing); - } - } - - for (const entry of markerIds.values()) { - entry.sort(); - } +describe("runCli", () => { + it("recognizes an npm-style symlinked executable without treating imports as entrypoints", { + skip: process.platform === "win32" ? "raw symlink creation is not portable on Windows" : false, + }, async () => { + const dir = await mkdtemp(path.join(tmpdir(), "simfile-cli-entrypoint-")); + const modulePath = path.join(dir, "index.js"); + const binPath = path.join(dir, "simfile"); + const importedPath = path.join(dir, "importer.js"); + await writeFile(modulePath, "export {};\n", "utf8"); + await writeFile(importedPath, "export {};\n", "utf8"); + await symlink(modulePath, binPath); - return markerIds; -}; + const moduleUrl = pathToFileURL(modulePath).href; + assert.equal(isCliEntrypoint(moduleUrl, binPath), true); + assert.equal(isCliEntrypoint(moduleUrl, importedPath), false); + assert.equal(isCliEntrypoint(moduleUrl, undefined), false); + assert.equal(isCliEntrypoint(moduleUrl, path.join(dir, "missing")), false); + }); -describe("runCli", () => { it("validates a Simfile path", async () => { const dir = await mkdtemp(path.join(tmpdir(), "simfile-cli-")); const file = path.join(dir, "Simfile.yaml"); @@ -141,7 +87,7 @@ variables: rules: hello: when: - event: wake.recommended + event: world.message scope: room:office:desk do: - action: moltnet:message @@ -178,7 +124,7 @@ clock: rules: hello: when: - event: wake.recommended + event: world.message target: room:office:missing-room do: - action: moltnet:message @@ -224,12 +170,13 @@ rules: variable: pressure above: 0.85 do: - - action: wake:recommend + - action: moltnet:message to: room:office-floor:case-warroom + content: "Observation notice." probes: deadline_seen: when: - event: wake.recommended + event: world.message target: room:office-floor:case-warroom expect: at_least: 1 @@ -239,7 +186,7 @@ probes: assert.equal(code, 0); await stat(path.join(out, "manifest.yaml")); const ledger = await readFile(path.join(out, "ledger.jsonl"), "utf8"); - assert.match(ledger, /"kind":"wake.recommended"/); + assert.match(ledger, /"kind":"world.message"/); const report = JSON.parse(await readFile(path.join(out, "report.json"), "utf8")) as { probes: Array<{ passed: boolean }> }; assert.equal(report.probes[0]?.passed, true); const telemetry = JSON.parse(await readFile(path.join(out, "telemetry.json"), "utf8")) as { @@ -296,7 +243,7 @@ probes: assert.equal(transcript.source, "harness-derived"); assert.deepEqual( transcript.entries.map((entry) => entry.kind), - ["world.message", "world.dm", "wake.recommended"] + ["world.message", "world.dm", "world.message"] ); assert.deepEqual( transcript.entries.find((entry) => entry.kind === "world.message") @@ -352,8 +299,9 @@ rules: variable: pressure above: 0.85 do: - - action: wake:recommend + - action: moltnet:message to: room:office-floor:case-warroom + content: "Observation notice." `, "utf8"); await writeFile(report, JSON.stringify({ nodes: [ @@ -373,4 +321,47 @@ rules: assert.equal(code, 0); await stat(path.join(out, "manifest.yaml")); }); + it("runs a compliant dynamics provider through the CLI", async () => { + const { createDynamicsTestProject, removeDynamicsTestProject } = + await import("../dynamics/testSupport.test-helper.js"); + const project = await createDynamicsTestProject(); + try { + const out = path.join(project.directory, "run"); + const { output, result } = await captureStdout(() => runCli( + ["run", project.simfilePath, "--ticks", "2", "--out", out])); + assert.equal(result, 0); + assert.equal(output, `wrote run dynamics-seed to ${out}\n`); + await stat(path.join(out, "replay/final-session.json")); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("rejects --acts on a dynamics run before output work", async () => { + const { createDynamicsTestProject, removeDynamicsTestProject } = + await import("../dynamics/testSupport.test-helper.js"); + const project = await createDynamicsTestProject(); + try { + const out = path.join(project.directory, "run"); + assert.equal(await runCli(["run", project.simfilePath, "--ticks", "1", + "--out", out, "--acts", path.join(project.directory, "missing")]), 1); + await assert.rejects(stat(out)); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("rejects --moltnet-artifact on a dynamics run before output work", async () => { + const { createDynamicsTestProject, removeDynamicsTestProject } = + await import("../dynamics/testSupport.test-helper.js"); + const project = await createDynamicsTestProject(); + try { + const out = path.join(project.directory, "run"); + assert.equal(await runCli(["run", project.simfilePath, "--ticks", "1", + "--out", out, "--moltnet-artifact", "transcript"]), 1); + await assert.rejects(stat(out)); + } finally { + await removeDynamicsTestProject(project); + } + }); }); diff --git a/src/cli/index.ts b/src/cli/index.ts index 5aa1525..6ec8e6d 100644 --- a/src/cli/index.ts +++ b/src/cli/index.ts @@ -1,22 +1,29 @@ #!/usr/bin/env node +import { realpathSync } from "node:fs"; import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; +import { fileURLToPath } from "node:url"; import { ZodError } from "zod"; +import { executeSimfileRun } from "../run/run-driver.js"; import { type BindingDiagnostic, type Simfile, createBindingDiagnostics, loadSpawnfileReport, parseSimfileSource } from "../schema/index.js"; -import { runObserve, writeObserveReport } from "../observe/index.js"; -import { type MoltnetArtifactKind, writeRunRecord } from "../runtime/trace.js"; -import type { QueuedWorldAct } from "../runtime/types.js"; +import { runObserveCommand } from "../observe/observeCommand.js"; import { runViewCommand } from "../view/index.js"; +import { runLinkedComposedCommand, type LinkedComposedRunCommand } from "./composedRunCommand.js"; +import { runRecoverCli } from "./recover.js"; +import { parseRunArguments } from "./runArguments.js"; +import { resolveSimfileRunRoute } from "./runRoute.js"; const usage = (): string => [ "Usage:", " simfile validate [--json] [--spawnfile-report |]", - " simfile run --ticks [--out ] [--seed ] [--run-id ] [--acts ] [--moltnet-artifact transcript|delivery] [--spawnfile-report |]", + " simfile run [--view] [--out ] [--seed ] [--run-id ]", + " simfile run --local --ticks [--out ] [--seed ] [--run-id ] [--acts ] [--clock ] [--moltnet-artifact transcript|delivery] [--spawnfile-report |]", " simfile observe [--json]", " simfile view --state ", " simfile view ", + " simfile recover --journal --run-id --authority-digest ", " simfile view --help", " simfile --help", "" @@ -32,49 +39,12 @@ const formatError = (error: unknown): string => { return error instanceof Error ? error.message : String(error); }; -interface ParsedRunOptions { - actsPath?: string; - moltnetArtifact?: MoltnetArtifactKind; - outDir?: string; - path?: string; - runId?: string; - seed?: string; - ticks?: number; - spawnfileReport?: string; -} - interface ParsedValidateOptions { json?: boolean; path?: string; spawnfileReport?: string; } -interface ParsedObserveOptions { - json?: boolean; - runDir?: string; -} - -const parseObserveArguments = (argv: readonly string[]): { error?: string; options?: ParsedObserveOptions } => { - const options: ParsedObserveOptions = {}; - - for (const arg of argv) { - if (arg === "--json") { - options.json = true; - continue; - } - if (arg.startsWith("-")) { - return { error: `Unknown flag ${arg}` }; - } - if (options.runDir !== undefined) { - return { error: `Unexpected positional argument ${arg}` }; - } - options.runDir = arg; - } - - if (!options.runDir) return { error: "Missing run directory" }; - return { options }; -}; - const parseOptionalValueFlag = ( arg: string, argv: readonly string[], @@ -138,109 +108,6 @@ const parseValidateArguments = (argv: readonly string[]): { return { options }; }; -const parseRunArguments = (argv: readonly string[]): { error?: string; options?: ParsedRunOptions } => { - const options: ParsedRunOptions = {}; - - for (let index = 0; index < argv.length; index += 1) { - const arg = argv[index]; - - if (arg === "--ticks") { - const value = argv[index + 1]; - if (!value) return { error: "Missing value for --ticks" }; - const ticks = Number(value); - if (!Number.isInteger(ticks) || ticks < 0) return { error: "Invalid value for --ticks" }; - options.ticks = ticks; - index += 1; - continue; - } - - if (arg.startsWith("--ticks=")) { - const ticks = Number(arg.slice("--ticks=".length)); - if (!Number.isInteger(ticks) || ticks < 0) return { error: "Invalid value for --ticks" }; - options.ticks = ticks; - continue; - } - - if (arg === "--moltnet-artifact") { - const value = argv[index + 1]; - if (value !== "delivery" && value !== "transcript") { - return { error: "Invalid value for --moltnet-artifact" }; - } - options.moltnetArtifact = value; - index += 1; - continue; - } - - if (arg.startsWith("--moltnet-artifact=")) { - const value = arg.slice("--moltnet-artifact=".length); - if (value !== "delivery" && value !== "transcript") { - return { error: "Invalid value for --moltnet-artifact" }; - } - options.moltnetArtifact = value; - continue; - } - - if (arg === "--out" || arg === "--seed" || arg === "--run-id" || arg === "--acts") { - const parsed = parseOptionalValueFlag(arg, argv, index, arg, `Missing value for ${arg}`); - if (parsed.error) return { error: parsed.error }; - if (parsed.value === undefined) return { error: `Missing value for ${arg}` }; - if (arg === "--out") options.outDir = parsed.value; - if (arg === "--seed") options.seed = parsed.value; - if (arg === "--run-id") options.runId = parsed.value; - if (arg === "--acts") options.actsPath = parsed.value; - index += 1; - continue; - } - - if (arg.startsWith("--out=")) { - options.outDir = arg.slice("--out=".length); - continue; - } - - if (arg.startsWith("--seed=")) { - options.seed = arg.slice("--seed=".length); - continue; - } - - if (arg.startsWith("--run-id=")) { - options.runId = arg.slice("--run-id=".length); - continue; - } - - if (arg.startsWith("--acts=")) { - options.actsPath = arg.slice("--acts=".length); - continue; - } - - const parsedReport = parseOptionalValueFlag( - arg, - argv, - index, - "--spawnfile-report", - "Missing value for --spawnfile-report" - ); - if (parsedReport.error) return { error: parsedReport.error }; - if (parsedReport.consumed) { - options.spawnfileReport = parsedReport.value; - if (arg === "--spawnfile-report") index += 1; - continue; - } - - if (arg.startsWith("-")) { - return { error: `Unknown flag ${arg}` }; - } - - if (options.path !== undefined) { - return { error: `Unexpected positional argument ${arg}` }; - } - options.path = arg; - } - - if (!options.path) return { error: "Missing Simfile path" }; - if (options.ticks === undefined) return { error: "Missing required --ticks" }; - return { options }; -}; - const defaultRunId = (seed: string): string => seed.replace(/[^a-zA-Z0-9_.-]+/gu, "-").replace(/^-+|-+$/gu, "") || "run"; const toBindingDiagnostics = (warnings: string[]): BindingDiagnostic[] => @@ -259,17 +126,6 @@ const bindingWarningsFromReport = async ( const hasErrorDiagnostic = (diagnostics: BindingDiagnostic[]): boolean => diagnostics.some((diagnostic) => diagnostic.level === "error"); -const loadQueuedWorldActs = async (actsPath?: string): Promise => { - if (!actsPath) { - return undefined; - } - const raw = JSON.parse(await readFile(actsPath, "utf8")) as unknown; - if (!Array.isArray(raw)) { - throw new Error(`--acts file must contain a JSON array: ${actsPath}`); - } - return raw as QueuedWorldAct[]; -}; - const printDiagnostics = (diagnostics: BindingDiagnostic[]): void => { for (const diagnostic of diagnostics) { const prefix = diagnostic.level === "error" ? "error" : "warning"; @@ -277,7 +133,10 @@ const printDiagnostics = (diagnostics: BindingDiagnostic[]): void => { } }; -export const runCli = async (argv: readonly string[]): Promise => { +export const runCli = async ( + argv: readonly string[], + dependencies: Readonly<{ runComposed?: LinkedComposedRunCommand }> = {}, +): Promise => { const [command, ...rest] = argv; if (command === "--help" || command === "-h" || command === undefined) { @@ -323,43 +182,54 @@ export const runCli = async (argv: readonly string[]): Promise => { } if (command === "run") { - const parsed = parseRunArguments(rest); - if (parsed.error || !parsed.options?.path || parsed.options.ticks === undefined) { - if (parsed.error) process.stderr.write(`${parsed.error}\n`); + let options; + try { + options = parseRunArguments(rest); + } catch (error) { + process.stderr.write(`${formatError(error)}\n`); process.stderr.write(usage()); return 1; } - - const path = parsed.options.path; + const simfilePath = options.path; try { - const source = await readFile(path, "utf8"); - const result = parseSimfileSource(source, { path }); + const source = await readFile(simfilePath, "utf8"); + const result = parseSimfileSource(source, { path: simfilePath }); + const route = resolveSimfileRunRoute({ options, simfile: result.simfile, simfilePath }); const diagnostics = [ ...toBindingDiagnostics(result.warnings), - ...await bindingWarningsFromReport(result.simfile, parsed.options.spawnfileReport) + ...await bindingWarningsFromReport(result.simfile, options.spawnfileReport) ]; printDiagnostics(diagnostics); if (hasErrorDiagnostic(diagnostics)) { process.stderr.write("failed to validate simulation before running\n"); return 1; } - - const seed = parsed.options.seed ?? result.simfile.clock.seed; - const runId = parsed.options.runId ?? defaultRunId(seed); - const outDir = resolve(parsed.options.outDir ?? `runs/${runId}`); - const worldActs = await loadQueuedWorldActs(parsed.options.actsPath); - const record = await writeRunRecord({ - moltnetArtifact: parsed.options.moltnetArtifact, + if (route.kind === "composed") { + return (dependencies.runComposed ?? runLinkedComposedCommand)({ + linked_spawnfile_path: route.linked_spawnfile_path, + options, + simfile: result.simfile, + simfile_path: simfilePath, + source_text: source, + }); + } + const seed = options.seed ?? result.simfile.clock.seed; + const runId = options.runId ?? defaultRunId(seed); + const outDir = resolve(options.outDir ?? `runs/${runId}`); + const clock = options.clock; + const run = await executeSimfileRun({ + actsPath: options.actsPath, + clock: clock === undefined ? () => new Date() : () => new Date(clock), + moltnetArtifact: options.moltnetArtifact, outDir, runId, seed, simfile: result.simfile, - sourcePath: path, + simfilePath, sourceText: source, - ticks: parsed.options.ticks, - worldActs + ticks: options.ticks! }); - process.stdout.write(`wrote run ${runId} to ${record.outDir}\n`); + process.stdout.write(`wrote run ${runId} to ${run.outDir}\n`); return 0; } catch (error) { process.stderr.write(`${formatError(error)}\n`); @@ -367,50 +237,19 @@ export const runCli = async (argv: readonly string[]): Promise => { } } - if (command === "observe") { - const parsed = parseObserveArguments(rest); - if (parsed.error || !parsed.options?.runDir) { - if (parsed.error) process.stderr.write(`${parsed.error}\n`); - process.stderr.write(usage()); - return 1; - } - - const runDir = resolve(parsed.options.runDir); + if (command === "recover") { try { - const result = await runObserve(runDir); - const reportPath = await writeObserveReport(runDir, result.report); - - const failedArtifacts = result.artifactIntegrity.filter((check) => !check.ok); - for (const failed of failedArtifacts) { - process.stderr.write( - `warning: artifact sha256 mismatch for ${failed.path} (expected ${failed.expectedSha256}, got ${failed.actualSha256 ?? ""})\n` - ); - } - for (const parseError of result.causalParseErrors) { - process.stderr.write(`warning: ${parseError.relativePath}:${parseError.line}: ${parseError.message}\n`); - } - - if (parsed.options.json) { - process.stdout.write(`${JSON.stringify({ - artifactIntegrity: result.artifactIntegrity, - causalParseErrors: result.causalParseErrors, - report: result.report, - reportPath - }, null, 2)}\n`); - } else { - process.stdout.write(`wrote observe report for run ${result.report.run_id} to ${reportPath}\n`); - process.stdout.write(`participants: ${result.report.participants.join(", ")}\n`); - process.stdout.write(`agent turns: ${result.report.agent_turns.count} (${result.report.agent_turns.sequence.join(" -> ")})\n`); - process.stdout.write(`chains: ${result.report.chains.complete} complete, ${result.report.chains.incomplete.length} incomplete\n`); - process.stdout.write(`failures: ${result.report.failures.length}\n`); - } - return failedArtifacts.length > 0 ? 1 : 0; + return await runRecoverCli(rest); } catch (error) { process.stderr.write(`${formatError(error)}\n`); return 1; } } + if (command === "observe") { + return runObserveCommand(rest, { formatError, usage }); + } + if (command === "view") { return runViewCommand(rest); } @@ -424,6 +263,15 @@ export const runCli = async (argv: readonly string[]): Promise => { return 1; }; -if (import.meta.url === `file://${process.argv[1]}`) { +export const isCliEntrypoint = (moduleUrl: string, argvPath: string | undefined): boolean => { + if (argvPath === undefined) return false; + try { + return realpathSync(fileURLToPath(moduleUrl)) === realpathSync(argvPath); + } catch { + return false; + } +}; + +if (isCliEntrypoint(import.meta.url, process.argv[1])) { process.exitCode = await runCli(process.argv.slice(2)); } diff --git a/src/cli/recover.test.ts b/src/cli/recover.test.ts new file mode 100644 index 0000000..102d0fa --- /dev/null +++ b/src/cli/recover.test.ts @@ -0,0 +1,404 @@ +import assert from "node:assert/strict"; +import { execFile, spawn } from "node:child_process"; +import { chmod, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { promisify } from "node:util"; +import test from "node:test"; + +import { createComposedPhaseJournal, writeComposedPhaseJournal } from "../compose/journal.js"; +import { digestComposedJson } from "../compose/json.js"; +import { lifecycleOrganizationUpReceipt, lifecyclePreparation, lifecycleReadiness, lifecycleRequest } from "../compose/lifecycle.test-helper.js"; +import { createComposedRunHarness } from "../compose/run.test-helper.js"; +import { composedRecoveryCommand } from "../compose/receipt.js"; +import { ensurePublicPackageBuild } from "../publicPackageBuild.test-helper.js"; +import { builtRecoveryEffectCount, builtRecoveryProviderCommand, createForeignExecutionJournal, expectBuiltForeignJournalRejections, expectBuiltRecoveryArgumentRejections, expectBuiltRecoveryAuthorityFailure, failedBuiltRecovery, organizationExport, type BuiltRecovery } from "./recoverAuthority.test-helper.js"; + +const execute = promisify(execFile); +const fixtureScript = (fixed: unknown): string => `#!/usr/bin/env node +import crypto from "node:crypto"; +import fs from "node:fs"; +const fixed=${JSON.stringify(fixed)}; +const argv=process.argv.slice(2); +fs.appendFileSync(fixed.logPath,JSON.stringify(argv)+"\\n"); +const canonical=(v)=>Array.isArray(v)?"["+v.map(canonical).join(",")+"]":v!==null&&typeof v==="object"?"{"+Object.keys(v).sort().map(k=>JSON.stringify(k)+":"+canonical(v[k])).join(",")+"}":JSON.stringify(v); +const digest=(domain,v)=>"sha256:"+crypto.createHash("sha256").update(domain+"\\0").update(canonical(v)).digest("hex"); +const seal=(domain,body)=>({...body,receipt_digest:digest(domain,body)}); +const output=(value)=>process.stdout.write(canonical(value)); +const crashWindow=(command,key)=>{ + if(fs.existsSync(fixed.beforeCrash)&&fs.readFileSync(fixed.beforeCrash,"utf8").trim()===command){fs.unlinkSync(fixed.beforeCrash);process.exit(86);} + const effects=fs.existsSync(fixed.effectState)?JSON.parse(fs.readFileSync(fixed.effectState,"utf8")):{}; + effects[command+":"+key]??=1; + fs.writeFileSync(fixed.effectState,JSON.stringify(effects)); + if(fs.existsSync(fixed.swapAfter)) { + const [mode,wanted]=fs.readFileSync(fixed.swapAfter,"utf8").trim().split(":"); + if(wanted===command){fs.unlinkSync(fixed.swapAfter);if(mode==="symlink"){fs.unlinkSync(fixed.journalPath);fs.symlinkSync(fixed.foreignJournal,fixed.journalPath);}else fs.renameSync(fixed.foreignJournal,fixed.journalPath);} + } + if(fs.existsSync(fixed.afterCrash)&&fs.readFileSync(fixed.afterCrash,"utf8").trim()===command){fs.unlinkSync(fixed.afterCrash);process.exit(87);} +}; +const requestFile=argv.at(-1); +const request=requestFile&&fs.existsSync(requestFile)?JSON.parse(fs.readFileSync(requestFile,"utf8")):undefined; +if(argv[0]==="target"&&argv[3]===fixed.hangCommand&&fs.existsSync(fixed.hangFlag)) { + fs.writeFileSync(fixed.childPid,String(process.pid)); + process.on("SIGTERM",()=>{}); + setInterval(()=>{},1000); + await new Promise(()=>{}); +} +if(argv[0]==="up") { crashWindow("up",argv.at(-1)); output(fixed.up); } +else if(argv[0]==="artifacts") { crashWindow("artifacts_export",argv.at(-1)); output(fixed.exportResult); } +else if(argv[0]==="down") { crashWindow("down",argv.at(-1)); output({version:"spawnfile.down-receipt.v1",deployment:"organization-unit",units_stopped:["organization-unit"],retained_volumes:[],errors:[]}); } +else if(argv[0]==="target") { + const command=argv[3]; + const selected={fingerprint:"sha256:"+"1".repeat(32),handle:"opaque_"+"6".repeat(16)}; + if(command==="prepare_composed_run") { + crashWindow(command,request.idempotency_key); + const {receipt_digest:_,...template}=fixed.preparation; + output(seal("spawnfile.composed-preparation.receipt.v1",{...template,request_digest:digest("spawnfile.composed-preparation.request.v1",request)})); + } else if(command==="query_world_readiness") { + output({readiness:fixed.readiness,readiness_digest:digest("spawnfile.target-world-readiness.document.v1",fixed.readiness),request_digest:digest("spawnfile.target-world-readiness.request.v1",request),run_id:request.run_id,version:"spawnfile.target-world-readiness-receipt.v1"}); + } else if(command==="attest_topology") { + output(seal("spawnfile.target-topology-receipt.v1",{descriptor_digest:request.descriptor_digest,handoff_scope:"organization_to_private_service",organization:{data_network_attachment:"exact",egress_policy:"egress_only"},request_digest:digest("spawnfile.target-topology-attestation.request.v1",request),run_id:request.run_id,selected_target:selected,service_discovery:"dns_only",version:"spawnfile.target-topology-receipt.v1",world_network:"private_internal",world_service:{data_network_attachment:"exactly_one",egress_policy:"none",published_ports:"none"}})); + } else if(command==="activate_topology") { + crashWindow(command,digest("spawnfile.target-topology-attestation.request.v1",request)); + const topology=seal("spawnfile.target-topology-receipt.v1",{descriptor_digest:request.descriptor_digest,handoff_scope:"organization_to_private_service",organization:{data_network_attachment:"exact",egress_policy:"egress_only"},request_digest:digest("spawnfile.target-topology-attestation.request.v1",request),run_id:request.run_id,selected_target:selected,service_discovery:"dns_only",version:"spawnfile.target-topology-receipt.v1",world_network:"private_internal",world_service:{data_network_attachment:"exactly_one",egress_policy:"none",published_ports:"none"}}); + const marker={bundle_digest:"sha256:"+"f".repeat(64),run_id:request.run_id,state:"activated",topology_receipt_digest:topology.receipt_digest,topology_request_digest:topology.request_digest,version:"spawnfile.world-service-activation.v1"}; + output(seal("spawnfile.target-topology-activation-receipt.v1",{activation_digest:digest("spawnfile.world-service-activation.v1",marker),bundle_digest:marker.bundle_digest,run_id:request.run_id,state:"activated",topology_receipt_digest:topology.receipt_digest,topology_request_digest:topology.request_digest,version:"spawnfile.target-topology-activation-receipt.v1"})); + } else if(command==="query_world_clock") { + const invalid=fs.existsSync(fixed.invalidClock)?fs.readFileSync(fixed.invalidClock,"utf8").trim():""; + const observedRun=invalid==="stale_run"?"run-stale-clock":request.run_id; + const clock=invalid==="never_tick"?{completed_tick:0,next_tick:1,state:"running"}:{completed_tick:1,next_tick:2,state:"running"}; + const observation={action_count:0,clock,run_id:observedRun,version:request.expected.document_version,world_instance_id:request.expected.world_instance_id}; + output(seal("spawnfile.target-world-clock-receipt.v1",{action_count:0,activation_digest:invalid==="activation_mismatch"?"sha256:"+"9".repeat(64):request.activation_digest,activation_receipt_digest:request.activation_receipt_digest,clock:observation.clock,observation_digest:digest("spawnfile.target-world-clock.observation.v1",observation),request_digest:digest("spawnfile.target-world-clock.request.v1",request),run_id:observedRun,topology_receipt_digest:invalid==="topology_forgery"?"sha256:"+"8".repeat(64):request.topology_receipt_digest,topology_request_digest:request.topology_request_digest,version:"spawnfile.target-world-clock-receipt.v1",world_instance_id:request.expected.world_instance_id,world_service_handle:request.world_service_handle})); + } else if(command==="snapshot_public_artifact") { + const terminal={outcome_digest:"sha256:"+"0".repeat(64),reason:"completed",run_id:request.run_id,terminal_tick:4,version:"simfile.composed-world-terminal-signal.v1"}; + const bytes=Buffer.from(canonical(terminal)); + output({artifact_id:request.artifact.id,content_base64:bytes.toString("base64"),content_digest:"sha256:"+crypto.createHash("sha256").update(bytes).digest("hex"),media_type:"application/json",request_digest:digest("spawnfile.target-public-artifact-snapshot.request.v1",request),run_id:request.run_id,size_bytes:bytes.length,version:"spawnfile.target-public-artifact-snapshot.v1"}); + } else { + const results={create_world_service:"opaque_"+"2".repeat(16),start_world_service:request.world_service_handle,attach_organization:"opaque_"+"4".repeat(16),stop_world_service:request.world_service_handle,export_evidence_volume:"opaque_"+"7".repeat(16),detach_organization:request.organization_attachment_handle,revoke_secret_bindings:request.secret_bindings_handle,cleanup_run:null}; + const operationHandles={create_world_service:"g",start_world_service:"h",attach_organization:"i",stop_world_service:"j",export_evidence_volume:"k",detach_organization:"l",revoke_secret_bindings:"m",cleanup_run:"n"}; + crashWindow(command,request.idempotency_key); + const evidenceMode=fs.existsSync(fixed.invalidEvidence)?fs.readFileSync(fixed.invalidEvidence,"utf8").trim():""; + const evidenceFiles=[{bytes:1,path:"actions/log.jsonl",sha256:"sha256:"+"a".repeat(64)},{bytes:2,path:"checkpoints/final.json",sha256:"sha256:"+"b".repeat(64)},{bytes:3,path:"projections/world.json",sha256:"sha256:"+"c".repeat(64)}]; + if(evidenceMode==="missing") evidenceFiles.shift(); + if(evidenceMode==="extra") evidenceFiles.push({bytes:1,path:"foreign/data",sha256:"sha256:"+"e".repeat(64)}); + const evidenceIndex=command==="export_evidence_volume"?{evidence_digest:"sha256:"+"d".repeat(64),export_handle:results[command],files:evidenceFiles,item_count:evidenceMode==="tamper"?99:evidenceFiles.length,labels:[],run_id:request.run_id,source:{evidence_volume_handle:evidenceMode==="source_mismatch"?"opaque_"+"9".repeat(16):request.evidence_volume_handle,state:"preserved"},state:"exported",version:"spawnfile.target-resource.export-index.v1"}:undefined; + output(seal("spawnfile.target-resource.receipt.v1",{cleanup_state:command==="cleanup_run"?"removed":"not_requested",descriptor_digest:request.descriptor_digest,...(evidenceIndex?{evidence_index:evidenceIndex}:{}),export_state:command==="export_evidence_volume"?"exported":"not_requested",labels:[],operation:command,operation_handle:"opaque_"+operationHandles[command].repeat(16),request_digest:digest("spawnfile.target-resource.request.v1",request),result_handle:results[command],resulting_revision:request.expected_revision+1,run_id:request.run_id,selected_target:selected,version:"spawnfile.target-resource.receipt.v1"})); + } +} else process.exitCode=2; +`; + +test("built recover survives every public mutation window and executes its emitted command", async () => { + await ensurePublicPackageBuild(path.resolve(".")); + const root = await mkdtemp(path.join(tmpdir(), "simfile-built-recover-")); + try { + const request = lifecycleRequest({ run_id: "run-built-recovery" }); + const harness = createComposedRunHarness(request); + const journalPath = path.join(root, "journal.json"); + const fakeSpawnfile = path.join(root, "spawnfile.mjs"); + const logPath = path.join(root, "spawnfile.log"); + const producerLog = path.join(root, "producer.log"); + const hangFlag = path.join(root, "hang"); + const invalidClock = path.join(root, "invalid-clock"); + const invalidEvidence = path.join(root, "invalid-evidence"); + const childPid = path.join(root, "child.pid"); + const beforeCrash = path.join(root, "before-crash"); + const afterCrash = path.join(root, "after-crash"); + const effectState = path.join(root, "effects.json"); + const foreignJournal = path.join(root, "foreign-journal.json"); + const producer = path.join(root, "target-config-producer.mjs"); + const swapAfter = path.join(root, "swap-after"); + const requestDigest = digestComposedJson("simfile.composed-run-request.v1", request); + const exportInvocation = `lci_${digestComposedJson( + "simfile.composed-organization-export-operation.v1", + { operation: "artifacts_export", request_digest: requestDigest }, + ).slice(7, 39)}`; + const execution = { + configuration: { + organization_expectation: harness.configuration.organization_expectation, + readiness_expectation: harness.configuration.readiness_expectation, + terminal_tick: harness.configuration.terminal_tick, + topology_expectation: { + selected_target: harness.configuration.topology_expectation.selected_target, + }, + }, + provider: { + compiled_output_directory: path.join(root, "compiled"), + evidence_destination_directory: path.join(root, "evidence"), + evidence_mount_path: "/var/lib/simfile/evidence", + lifecycle_invocations: { + down: "lci_down_aaaaaaaaaaaa", export: exportInvocation, + up: "lci_up_aaaaaaaaaaaaaa", + }, + organization_handoff: { + env_file: path.join(root, "runtime.env"), + selected_target_receipt_file: path.join(root, "selected-target.json"), + world_bindings_file: path.join(root, "world-bindings.json"), + }, + organization_container_name: "organization-unit", + organization_image_tag: "organization-unit:run-built-recovery", + organization_path: path.join(root, "organization.yaml"), + spawnfile_bin: fakeSpawnfile, spawnfile_cwd: root, + target_config_producer: { + args: [request.target.selector], command: producer, + transport: "stdout_to_spawnfile_stdin", + }, + terminal_artifact: { + id: "terminal_receipt", max_bytes: 131_072, + path: "/tmp/spawnfile-public/terminal.json", + }, + world_readiness_port: 8080, + }, + secret_bindings: [{ name: "provider_key", scope: "world", source_handle: "opaque_bbbbbbbbbbbbbbbb" }], + version: "simfile.composed-execution.v1", + } as const; + await writeFile(producer, `#!/usr/bin/env node +import fs from "node:fs"; +fs.appendFileSync(${JSON.stringify(producerLog)}, process.argv[2] + "\\n"); +process.stdout.write('{}'); +`, { mode: 0o700 }); + await chmod(producer, 0o700); + await writeFile(fakeSpawnfile, fixtureScript({ + afterCrash, beforeCrash, childPid, effectState, foreignJournal, + invalidClock, invalidEvidence, swapAfter, + exportResult: organizationExport(request.run_id), hangCommand: "snapshot_public_artifact", + hangFlag, journalPath, logPath, + preparation: lifecyclePreparation(request), readiness: lifecycleReadiness(request), + up: lifecycleOrganizationUpReceipt(request.run_id, true), + }), { mode: 0o600 }); + const initialJournal = createComposedPhaseJournal( + request, "2026-08-07T00:00:00.000Z", execution, + ); + const authorityDigest = initialJournal.authority_digest; + await writeComposedPhaseJournal(journalPath, initialJournal); + const failedRecovery = (): Promise => failedBuiltRecovery({ authorityDigest, + cliPath: path.resolve("dist/cli/index.js"), cwd: path.resolve("."), journalPath, + runId: request.run_id }); + const effectCount = (command: string): Promise => + builtRecoveryEffectCount(effectState, command); + const providerCommand = builtRecoveryProviderCommand; + const rejectSwap = async (command: string, mode: "replace" | "symlink"): Promise => { + const owned = await readFile(journalPath); + const foreignRequest = lifecycleRequest({ run_id: "run-foreign-journal" }); + await writeComposedPhaseJournal(foreignJournal, createForeignExecutionJournal( + foreignRequest, "2026-08-07T00:00:00.000Z", execution, + )); + const foreignBytes = await readFile(foreignJournal, "utf8"); + const before = (await readFile(logPath, "utf8")).trim().split("\n").length; + await writeFile(swapAfter, `${mode}:${command}\n`); + await expectBuiltRecoveryAuthorityFailure({ + authorityDigest, cliPath: path.resolve("dist/cli/index.js"), cwd: path.resolve("."), + journalPath, runId: request.run_id, + }); + const calls = (await readFile(logPath, "utf8")).trim().split("\n") + .slice(before).map((line) => JSON.parse(line) as string[]); + const commands = calls.map(providerCommand); + const attempted = commands.indexOf(command); + assert.notEqual(attempted, -1, `${mode}:${command}`); + assert.deepEqual(commands.slice(attempted), [command], `${mode}:${command}`); + assert.equal(await readFile(journalPath, "utf8"), foreignBytes); + await rm(journalPath, { force: true }); + await writeFile(journalPath, owned, { mode: 0o600 }); + await rm(foreignJournal, { force: true }); + }; + const failBefore = async (command: string): Promise => { + await writeFile(beforeCrash, `${command}\n`); + const receipt = await failedRecovery(); + assert.equal(receipt.status, "recovery_required"); + assert.equal(await effectCount(command), 0, command); + return receipt; + }; + const failAfter = async (command: string): Promise => { + await writeFile(afterCrash, `${command}\n`); + const receipt = await failedRecovery(); + assert.equal(receipt.status, "recovery_required"); + assert.equal(await effectCount(command), 1, command); + return receipt; + }; + const beforeTerminal = [ + "prepare_composed_run", "create_world_service", "start_world_service", + "up", "attach_organization", "activate_topology", + ] as const; + await failBefore(beforeTerminal[0]); + for (const [index, command] of beforeTerminal.entries()) { + await failAfter(command); + const next = beforeTerminal[index + 1]; + if (next !== undefined) await failBefore(next); + } + for (const invalid of [ + "never_tick", "stale_run", "topology_forgery", "activation_mismatch", + ] as const) { + await writeFile(invalidClock, `${invalid}\n`); + const rejected = await failedRecovery(); + assert.equal(rejected.status, "recovery_required", invalid); + const rejectedJournal = JSON.parse(await readFile(journalPath, "utf8")) as { + current_phase: string; entries: Array<{ phase: string }>; + }; + assert.equal(rejectedJournal.current_phase, "activated", invalid); + assert.equal(rejectedJournal.entries.some(({ phase }) => phase === "tick_1"), false, invalid); + } + await rm(invalidClock); + await writeFile(hangFlag, "hang\n"); + const child = spawn(process.execPath, [ + path.resolve("dist/cli/index.js"), "recover", "--journal", journalPath, + "--run-id", request.run_id, "--authority-digest", authorityDigest, + ], { cwd: path.resolve("."), stdio: ["ignore", "pipe", "pipe"] }); + let interruptedStdout = ""; + let interruptedStderr = ""; + child.stdout.on("data", (chunk: Buffer) => { interruptedStdout += chunk.toString("utf8"); }); + child.stderr.on("data", (chunk: Buffer) => { interruptedStderr += chunk.toString("utf8"); }); + for (let attempt = 0; attempt < 250; attempt += 1) { + if (await readFile(childPid, "utf8").then(() => true).catch(() => false)) break; + await new Promise((resolve) => setTimeout(resolve, 20)); + } + const providerPidText = await readFile(childPid, "utf8").catch(() => ""); + assert.notEqual(providerPidText, "", `hung provider was not reached\nstdout=${interruptedStdout}\nstderr=${interruptedStderr}`); + const providerPid = Number(providerPidText); + const interruptedAt = Date.now(); + child.kill("SIGTERM"); + const exited = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>( + (resolve) => child.once("close", (code, signal) => resolve({ code, signal })), + ); + const recovery = JSON.parse(interruptedStdout) as { + recovery_command: string; signal: string; status: string; + }; + assert.deepEqual(exited, { code: 143, signal: null }, interruptedStdout); + assert.equal(interruptedStderr, ""); + assert.ok(Date.now() - interruptedAt < 5_000); + assert.equal(recovery.status, "recovery_required"); + assert.equal(recovery.signal, "SIGTERM"); + assert.equal(recovery.recovery_command, + composedRecoveryCommand(journalPath, request.run_id, authorityDigest)); + assert.throws(() => process.kill(providerPid, 0), /ESRCH/u); + const interruptedCalls = await readFile(logPath, "utf8"); + assert.doesNotMatch(interruptedCalls, /export_evidence_volume|artifacts|cleanup_run/u); + await rm(hangFlag); + const afterTerminal = [ + "stop_world_service", "export_evidence_volume", "artifacts_export", + "detach_organization", "down", "revoke_secret_bindings", "cleanup_run", + ] as const; + await failBefore(afterTerminal[0]); + const crossRunRequest = lifecycleRequest({ run_id: "run-foreign-journal" }); + const modifiedDescriptor = lifecycleRequest({ descriptor_digest: `sha256:${"9".repeat(64)}` }); + await expectBuiltForeignJournalRejections({ + authorityDigest, cliPath: path.resolve("dist/cli/index.js"), cwd: path.resolve("."), + foreignJournals: [ + createForeignExecutionJournal(crossRunRequest, "2026-08-07T00:00:00.000Z", execution), + createForeignExecutionJournal(request, "2026-08-07T00:00:00.000Z", execution), + createForeignExecutionJournal(modifiedDescriptor, "2026-08-07T00:00:00.000Z", execution), + ], + foreignPath: foreignJournal, journalPath, providerLogs: [logPath, producerLog], + runId: request.run_id, + }); + await rejectSwap(afterTerminal[0], "replace"); + let finalRecovery: BuiltRecovery | undefined = await failAfter(afterTerminal[0]); + await failBefore(afterTerminal[1]); + for (const invalid of ["missing", "extra", "tamper", "source_mismatch"] as const) { + await writeFile(invalidEvidence, `${invalid}\n`); + assert.equal((await failedRecovery()).status, "recovery_required", invalid); + const rejected = JSON.parse(await readFile(journalPath, "utf8")) as { + current_phase: string; entries: Array<{ phase: string }>; + }; + assert.equal(rejected.current_phase, "world_paused", invalid); + assert.equal(rejected.entries.some(({ phase }) => phase === "world_evidence_exported"), false); + } + await rm(invalidEvidence); + for (const [index, command] of afterTerminal.slice(1).entries()) { + await rejectSwap(command, index % 2 === 0 ? "symlink" : "replace"); + finalRecovery = await failAfter(command); + const next = afterTerminal[index + 2]; + if (next !== undefined) await failBefore(next); + } + const commandBin = path.join(root, "bin"); + await mkdir(commandBin); + const commandPath = path.join(commandBin, "simfile"); + await writeFile(commandPath, `#!/bin/sh +exec ${JSON.stringify(process.execPath)} ${JSON.stringify(path.resolve("dist/cli/index.js"))} "$@" +`, { mode: 0o700 }); + await chmod(commandPath, 0o700); + let stdout: string; + let stderr: string; + try { + ({ stdout, stderr } = await execute("/bin/sh", ["-c", finalRecovery!.recovery_command], { + cwd: path.resolve("."), env: { ...process.env, PATH: `${commandBin}:${process.env.PATH ?? ""}` }, + timeout: 30_000, + })); + } catch (error) { + const failed = error as Error & { stderr?: string; stdout?: string }; + const log = await readFile(logPath, "utf8").catch(() => ""); + throw new Error(`${failed.message}\nstdout=${failed.stdout ?? ""}\nstderr=${failed.stderr ?? ""}\nlog=${log}`); + } + assert.equal(stderr, ""); + const receipt = JSON.parse(stdout) as { status: string; run_id: string }; + assert.equal(receipt.status, "completed"); + assert.equal(receipt.run_id, request.run_id); + const stored = JSON.parse(await readFile(journalPath, "utf8")) as { + current_phase: string; state: string; + }; + assert.equal(stored.current_phase, "completed"); + assert.equal(stored.state, "complete"); + const calls = (await readFile(logPath, "utf8")).trim().split("\n").map( + (line) => JSON.parse(line) as string[], + ); + const targetCommands = calls.filter((call) => call[0] === "target").map((call) => call[3]); + for (const command of beforeTerminal.filter((value) => value !== "up")) { + assert.equal(targetCommands.filter((value) => value === command).length, 3, command); + } + for (const command of afterTerminal.filter((value) => + value !== "artifacts_export" && value !== "down")) { + assert.ok(targetCommands.filter((value) => value === command).length >= 3, command); + } + assert.equal(targetCommands.filter((value) => value === "query_world_readiness").length, 1); + assert.equal(targetCommands.filter((value) => value === "attest_topology").length, 1); + assert.equal(targetCommands.filter((value) => value === "query_world_clock").length, 5); + assert.equal(targetCommands.filter((value) => value === "snapshot_public_artifact").length, 2); + assert.ok(calls.filter((call) => call[0] === "up").length >= 3); + assert.ok(calls.filter((call) => call[0] === "artifacts").length >= 4); + assert.ok(calls.filter((call) => call[0] === "down").length >= 3); + assert.equal(calls.filter((call) => call[0] === "up") + .every((call) => call.at(-1) === "lci_up_aaaaaaaaaaaaaa"), true); + assert.equal(calls.filter((call) => call[0] === "artifacts") + .every((call) => call.at(-1) === exportInvocation), true); + assert.equal(calls.filter((call) => call[0] === "down") + .every((call) => call.at(-1) === "lci_down_aaaaaaaaaaaa"), true); + for (const command of [...beforeTerminal, ...afterTerminal]) { + assert.equal(await effectCount(command), 1, command); + } + assert.deepEqual((await readFile(producerLog, "utf8")).trim().split("\n"), + Array.from({ length: targetCommands.length }, () => request.target.selector)); + await expectBuiltRecoveryArgumentRejections({ + authorityDigest, cliPath: path.resolve("dist/cli/index.js"), cwd: path.resolve("."), + journalPath, providerLogs: [logPath, producerLog], runId: request.run_id, + }); + const callsBeforeRejections = calls.length; + const rejectJournal = async (candidate: string): Promise => { + await assert.rejects(execute(process.execPath, [ + path.resolve("dist/cli/index.js"), "recover", "--journal", candidate, + "--run-id", request.run_id, "--authority-digest", authorityDigest, + ], { cwd: path.resolve("."), timeout: 5_000 }), (error: unknown) => { + const failure = error as { code?: number; stdout?: string }; + return failure.code === 1 && failure.stdout === ""; + }); + }; + await rejectJournal(path.join(root, "missing.json")); + const malformed = path.join(root, "malformed.json"); + await writeFile(malformed, "{\n"); + await rejectJournal(malformed); + const secret = path.join(root, "secret.json"); + await writeFile(secret, '{"token":"token=must-not-load"}\n'); + await rejectJournal(secret); + const crossed = path.join(root, "crossed.json"); + const crossRun = JSON.parse(await readFile(journalPath, "utf8")) as Record; + const crossExecution = crossRun.execution as { + configuration: { readiness_expectation: { run_id: string } }; + }; + crossExecution.configuration.readiness_expectation.run_id = "run-foreign"; + const { journal_digest: _oldDigest, ...crossBody } = crossRun; + crossRun.journal_digest = digestComposedJson("simfile.composed-phase-journal.v1", crossBody); + await writeFile(crossed, `${JSON.stringify(crossRun)}\n`); + await rejectJournal(crossed); + assert.equal((await readFile(logPath, "utf8")).trim().split("\n").length, + callsBeforeRejections); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/src/cli/recover.ts b/src/cli/recover.ts new file mode 100644 index 0000000..0d28668 --- /dev/null +++ b/src/cli/recover.ts @@ -0,0 +1,36 @@ +import { composedRunConfiguration } from "../compose/execution.js"; +import { openComposedJournalSession } from "../compose/journalSession.js"; +import { parseComposedRecoveryArguments, recoverComposedRun } from "../compose/recovery.js"; +import { serializeComposedReceipt } from "../compose/receipt.js"; +import { createProductionComposedRunPorts } from "../spawnfile/productionPorts.js"; + +/** Restarts a composed lifecycle from its durable, nonsecret journal only. */ +export const runRecoverCli = async (argv: readonly string[]): Promise => { + const parsed = parseComposedRecoveryArguments(argv); + const expectedAuthority = { + authority_digest: parsed.authority_digest, + run_id: parsed.run_id, + }; + const journalSession = await openComposedJournalSession( + parsed.journal_path, expectedAuthority, + ); + const journal = journalSession.current(); + if (journal.execution === undefined) { + throw new TypeError("composed journal does not contain production recovery inputs"); + } + const outcome = await recoverComposedRun({ + configuration: composedRunConfiguration(journal.execution), + expected_authority: expectedAuthority, + journal_path: parsed.journal_path, + journal_session: journalSession, + ports: createProductionComposedRunPorts({ + execution: journal.execution, + journal_session: journalSession, + }), + }); + process.stdout.write(serializeComposedReceipt(outcome.receipt)); + if (outcome.receipt.status === "completed") return 0; + if (outcome.receipt.signal === "SIGINT") return 130; + if (outcome.receipt.signal === "SIGTERM") return 143; + return 1; +}; diff --git a/src/cli/recoverAuthority.test-helper.ts b/src/cli/recoverAuthority.test-helper.ts new file mode 100644 index 0000000..5660763 --- /dev/null +++ b/src/cli/recoverAuthority.test-helper.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { readFile, rename, rm, writeFile } from "node:fs/promises"; +import { promisify } from "node:util"; + +import { createComposedPhaseJournal } from "../compose/journal.js"; +import type { ComposedPhaseJournal } from "../compose/journal.js"; +import { parseComposedExecution } from "../compose/execution.js"; +import type { ComposedRunRequest } from "../compose/request.js"; + +const execute = promisify(execFile); + +export interface BuiltRecovery { readonly recovery_command: string; readonly status: string } + +export const failedBuiltRecovery = async (input: Readonly<{ + authorityDigest: string; + cliPath: string; + cwd: string; + journalPath: string; + runId: string; +}>): Promise => { + try { + await execute(process.execPath, [input.cliPath, "recover", "--journal", input.journalPath, + "--run-id", input.runId, "--authority-digest", input.authorityDigest], { + cwd: input.cwd, timeout: 10_000, + }); + assert.fail("crash-window recovery unexpectedly completed"); + } catch (error) { + const failure = error as { code?: number; stdout?: string }; + assert.equal(failure.code, 1); + return JSON.parse(failure.stdout ?? "") as BuiltRecovery; + } +}; + +export const builtRecoveryEffectCount = async ( + effectState: string, + command: string, +): Promise => { + const effects = await readFile(effectState, "utf8") + .then((value) => JSON.parse(value) as Record).catch(() => ({})); + return Object.keys(effects).filter((key) => key.startsWith(`${command}:`)).length; +}; + +export const builtRecoveryProviderCommand = (call: string[]): string => call[0] === "target" + ? call[3]! : call[0] === "artifacts" ? "artifacts_export" : call[0]!; + +export const organizationExport = (runId: string) => ({ + deployment: "organization-unit", failed_files: [], + index: { + deployment: "organization-unit", exported_at: "2026-01-01T00:00:14.000Z", + files: [ + { bytes: 1, path: "raw/daimon/member/log.jsonl", sha256: "a".repeat(64), source: { kind: "volume", ref: "d:/log" } }, + { bytes: 1, path: "raw/mneme/bank/log.jsonl", sha256: "b".repeat(64), source: { kind: "volume", ref: "m:/log" } }, + { bytes: 1, path: "raw/moltnet/log.jsonl", sha256: "c".repeat(64), source: { kind: "volume", ref: "n:/log" } }, + ], + run_id: runId, version: "spawnfile.export-index.v1", + }, + index_path: "/evidence/spawnfile/export-index.json", missing_optional_files: [], +}); + +export const createForeignExecutionJournal = ( + request: ComposedRunRequest, + recordedAt: string, + rawExecution: unknown, +) => { + const execution = parseComposedExecution(rawExecution); + return createComposedPhaseJournal(request, recordedAt, { + ...execution, + configuration: { + ...execution.configuration, + readiness_expectation: { + ...execution.configuration.readiness_expectation, + run_id: request.run_id, + }, + }, + }); +}; + +export const expectBuiltRecoveryAuthorityFailure = async (input: Readonly<{ + authorityDigest: string; + cliPath: string; + cwd: string; + journalPath: string; + runId: string; +}>): Promise => { + try { + await execute(process.execPath, [input.cliPath, "recover", "--journal", input.journalPath, + "--run-id", input.runId, "--authority-digest", input.authorityDigest], { + cwd: input.cwd, + timeout: 10_000, + }); + assert.fail("journal authority loss unexpectedly emitted a recovery receipt"); + } catch (error) { + const failure = error as { code?: number; stderr?: string; stdout?: string }; + assert.equal(failure.code, 1); + assert.equal(failure.stdout, ""); + assert.match(failure.stderr ?? "", + /^composed journal (?:authority changed|file (?:identity changed|is unsafe))\n$/u); + assert.ok((failure.stderr ?? "").length < 128); + assert.doesNotMatch(failure.stderr ?? "", /recover --journal|recovery_command|run-foreign/u); + } +}; + +const optionalBytes = async (file: string): Promise => + readFile(file, "utf8").catch(() => ""); + +export const expectBuiltForeignJournalRejections = async (input: Readonly<{ + authorityDigest: string; + cliPath: string; + cwd: string; + foreignJournals: readonly ComposedPhaseJournal[]; + foreignPath: string; + journalPath: string; + providerLogs: readonly string[]; + runId: string; +}>): Promise => { + const owned = await readFile(input.journalPath); + for (const [index, journal] of input.foreignJournals.entries()) { + await writeFile(input.foreignPath, `${JSON.stringify(journal)}\n`, { mode: 0o600 }); + const foreignBytes = await readFile(input.foreignPath, "utf8"); + const logsBefore = await Promise.all(input.providerLogs.map(optionalBytes)); + await rename(input.foreignPath, input.journalPath); + const reject = () => expectBuiltRecoveryAuthorityFailure({ + authorityDigest: input.authorityDigest, + cliPath: input.cliPath, + cwd: input.cwd, + journalPath: input.journalPath, + runId: input.runId, + }); + if (index === 0) { + await Promise.all([reject(), reject()]); + await reject(); + } else await reject(); + assert.equal(await readFile(input.journalPath, "utf8"), foreignBytes); + assert.deepEqual(await Promise.all(input.providerLogs.map(optionalBytes)), logsBefore); + await rm(input.journalPath, { force: true }); + await writeFile(input.journalPath, owned, { mode: 0o600 }); + } +}; + +export const expectBuiltRecoveryArgumentRejections = async (input: Readonly<{ + authorityDigest: string; + cliPath: string; + cwd: string; + journalPath: string; + providerLogs: readonly string[]; + runId: string; +}>): Promise => { + const base = [input.cliPath, "recover", "--journal", input.journalPath, + "--run-id", input.runId, "--authority-digest", input.authorityDigest]; + const logsBefore = await Promise.all(input.providerLogs.map(optionalBytes)); + for (const argv of [ + base.slice(0, 4), + [...base, "--run-id", input.runId], + [...base, "--unknown", "value"], + ]) { + try { + await execute(process.execPath, argv, { cwd: input.cwd, timeout: 10_000 }); + assert.fail("invalid recovery arguments unexpectedly ran"); + } catch (error) { + const failure = error as { code?: number; stderr?: string; stdout?: string }; + assert.equal(failure.code, 1); + assert.equal(failure.stdout, ""); + assert.equal(failure.stderr, + "usage: simfile recover --journal --run-id --authority-digest \n"); + } + } + assert.deepEqual(await Promise.all(input.providerLogs.map(optionalBytes)), logsBefore); +}; diff --git a/src/cli/runArguments.test.ts b/src/cli/runArguments.test.ts new file mode 100644 index 0000000..79c08fc --- /dev/null +++ b/src/cli/runArguments.test.ts @@ -0,0 +1,49 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { parseRunArguments } from "./runArguments.js"; + +describe("run argument matrix", () => { + const values = Object.freeze([ + ["--ticks", "3", "ticks", 3], + ["--out", "runs/one", "outDir", "runs/one"], + ["--seed", "seed-one", "seed", "seed-one"], + ["--run-id", "run-one", "runId", "run-one"], + ["--acts", "acts.json", "actsPath", "acts.json"], + ["--clock", "2026-08-07T00:00:00Z", "clock", "2026-08-07T00:00:00Z"], + ["--moltnet-artifact", "transcript", "moltnetArtifact", "transcript"], + ["--spawnfile-report", "report.json", "spawnfileReport", "report.json"], + ] as const); + + for (const [flag, value, key, expected] of values) { + it(`accepts ${flag} in split and equals forms`, () => { + assert.equal(parseRunArguments(["Simfile", flag, value])[key], expected); + assert.equal(parseRunArguments(["Simfile", `${flag}=${value}`])[key], expected); + }); + it(`rejects absent ${flag} values`, () => { + assert.throws(() => parseRunArguments(["Simfile", flag]), /Missing value/u); + assert.throws(() => parseRunArguments(["Simfile", `${flag}=`]), /Missing value/u); + assert.throws(() => parseRunArguments(["Simfile", flag, "--view"]), /Missing value/u); + }); + } + + it("accepts local/view switches and rejects duplicate or unknown flags", () => { + assert.equal(parseRunArguments(["Simfile", "--local"]).local, true); + assert.equal(parseRunArguments(["Simfile", "--view"]).view, true); + assert.throws(() => parseRunArguments(["Simfile", "--view", "--view"]), /Duplicate/u); + assert.throws(() => parseRunArguments(["Simfile", "--future"]), /Unknown flag/u); + assert.throws(() => parseRunArguments([]), /Missing Simfile/u); + assert.throws(() => parseRunArguments(["one", "two"]), /Unexpected positional/u); + }); + + it("rejects invalid, unsafe, and duplicate valued flags", () => { + for (const value of ["-1", "1.5", "NaN", "Infinity"]) { + assert.throws(() => parseRunArguments(["Simfile", `--ticks=${value}`]), /Invalid/u); + } + assert.throws(() => parseRunArguments(["Simfile", "--out=a", "--out=b"]), /Duplicate/u); + assert.throws(() => parseRunArguments([ + "Simfile", "--moltnet-artifact=messages", + ]), /Invalid/u); + }); +}); + diff --git a/src/cli/runArguments.ts b/src/cli/runArguments.ts new file mode 100644 index 0000000..d42927c --- /dev/null +++ b/src/cli/runArguments.ts @@ -0,0 +1,118 @@ +import type { MoltnetArtifactKind } from "../runtime/trace.js"; + +export interface ParsedRunOptions { + readonly actsPath?: string; + readonly clock?: string; + readonly local: boolean; + readonly moltnetArtifact?: MoltnetArtifactKind; + readonly outDir?: string; + readonly path: string; + readonly runId?: string; + readonly seed?: string; + readonly spawnfileReport?: string; + readonly ticks?: number; + readonly view: boolean; +} + +type MutableRunOptions = { + -readonly [Key in keyof Omit]?: + ParsedRunOptions[Key]; +} & { + local?: boolean; + path?: string; + view?: boolean; +}; + +const valueFlags = Object.freeze({ + "--acts": "actsPath", + "--clock": "clock", + "--out": "outDir", + "--run-id": "runId", + "--seed": "seed", + "--spawnfile-report": "spawnfileReport", +} as const); + +const flagValue = ( + arg: string, + argv: readonly string[], + index: number, + flag: string, +): { readonly consumed: number; readonly value?: string } | undefined => { + if (arg === flag) { + const value = argv[index + 1]; + if (value === undefined || value.length === 0 || value.startsWith("--")) { + throw new TypeError(`Missing value for ${flag}`); + } + return { consumed: 2, value }; + } + if (arg.startsWith(`${flag}=`)) { + const value = arg.slice(flag.length + 1); + if (value.length === 0) throw new TypeError(`Missing value for ${flag}`); + return { consumed: 1, value }; + } + return undefined; +}; + +const parseTicks = (value: string): number => { + const ticks = Number(value); + if (!Number.isSafeInteger(ticks) || ticks < 0) { + throw new TypeError("Invalid value for --ticks"); + } + return ticks; +}; + +/** Parses the complete run flag surface without opening lifecycle authority. */ +export const parseRunArguments = (argv: readonly string[]): ParsedRunOptions => { + const options: MutableRunOptions = {}; + for (let index = 0; index < argv.length;) { + const arg = argv[index]!; + if (arg === "--local" || arg === "--view") { + const key = arg === "--local" ? "local" : "view"; + if (options[key]) throw new TypeError(`Duplicate flag ${arg}`); + options[key] = true; + index += 1; + continue; + } + const ticks = flagValue(arg, argv, index, "--ticks"); + if (ticks !== undefined) { + if (options.ticks !== undefined) throw new TypeError("Duplicate flag --ticks"); + options.ticks = parseTicks(ticks.value!); + index += ticks.consumed; + continue; + } + const artifact = flagValue(arg, argv, index, "--moltnet-artifact"); + if (artifact !== undefined) { + if (options.moltnetArtifact !== undefined) { + throw new TypeError("Duplicate flag --moltnet-artifact"); + } + if (artifact.value !== "delivery" && artifact.value !== "transcript") { + throw new TypeError("Invalid value for --moltnet-artifact"); + } + options.moltnetArtifact = artifact.value; + index += artifact.consumed; + continue; + } + let matched = false; + for (const [flag, key] of Object.entries(valueFlags) as Array< + [keyof typeof valueFlags, (typeof valueFlags)[keyof typeof valueFlags]] + >) { + const parsed = flagValue(arg, argv, index, flag); + if (parsed === undefined) continue; + if (options[key] !== undefined) throw new TypeError(`Duplicate flag ${flag}`); + options[key] = parsed.value; + index += parsed.consumed; + matched = true; + break; + } + if (matched) continue; + if (arg.startsWith("-")) throw new TypeError(`Unknown flag ${arg}`); + if (options.path !== undefined) { + throw new TypeError(`Unexpected positional argument ${arg}`); + } + options.path = arg; + index += 1; + } + if (options.path === undefined) throw new TypeError("Missing Simfile path"); + return Object.freeze({ ...options, local: options.local ?? false, + path: options.path, view: options.view ?? false }); +}; diff --git a/src/cli/runRoute.test.ts b/src/cli/runRoute.test.ts new file mode 100644 index 0000000..cffd876 --- /dev/null +++ b/src/cli/runRoute.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { parseSimfileSource } from "../schema/index.js"; +import { parseRunArguments } from "./runArguments.js"; +import { resolveSimfileRunRoute } from "./runRoute.js"; + +const source = (linked: boolean): string => ` +simfile_version: "0.1" +name: route-test +${linked ? "spawnfile: ./organization/Spawnfile" : ""} +clock: + seed: route-test + tick: 1s +`; +const route = (linked: boolean, argv: readonly string[]) => resolveSimfileRunRoute({ + options: parseRunArguments(["/work/Simfile", ...argv]), + simfile: parseSimfileSource(source(linked)).simfile, + simfilePath: "/work/Simfile", +}); + +describe("run routing", () => { + it("routes only an authored resolved link to composition", () => { + assert.deepEqual(route(true, []), { + kind: "composed", linked_spawnfile_path: "/work/organization/Spawnfile", + }); + assert.deepEqual(route(true, ["--local", "--ticks", "2"]), { + kind: "local", linked_spawnfile_path: "/work/organization/Spawnfile", + }); + assert.deepEqual(route(false, ["--ticks=2"]), { kind: "local" }); + }); + + it("rejects every prepared/scripted input before composed dispatch", () => { + for (const args of [ + ["--acts", "acts.json"], ["--clock", "2026-08-07T00:00:00Z"], + ["--moltnet-artifact", "transcript"], ["--spawnfile-report", "report.json"], + ]) assert.throws(() => route(true, args), /Linked composed runs reject/u); + assert.throws(() => route(true, ["--ticks", "2"]), /use --local/u); + }); + + it("preserves bounded unlinked/local compatibility", () => { + assert.throws(() => route(false, []), /require --ticks/u); + assert.throws(() => route(true, ["--local"]), /require --ticks/u); + assert.throws(() => route(false, ["--ticks", "1", "--view"]), /reject --view/u); + }); +}); + diff --git a/src/cli/runRoute.ts b/src/cli/runRoute.ts new file mode 100644 index 0000000..1d1379b --- /dev/null +++ b/src/cli/runRoute.ts @@ -0,0 +1,47 @@ +import path from "node:path"; + +import type { Simfile } from "../schema/index.js"; +import type { ParsedRunOptions } from "./runArguments.js"; + +export type SimfileRunRoute = Readonly< + | { kind: "composed"; linked_spawnfile_path: string } + | { kind: "local"; linked_spawnfile_path?: string } +>; + +const composedForbidden = Object.freeze([ + ["actsPath", "--acts"], + ["clock", "--clock"], + ["moltnetArtifact", "--moltnet-artifact"], + ["spawnfileReport", "--spawnfile-report"], +] as const); + +/** Selects linked composition solely from the resolved authored Spawnfile link. */ +export const resolveSimfileRunRoute = (input: Readonly<{ + options: ParsedRunOptions; + simfile: Simfile; + simfilePath: string; +}>): SimfileRunRoute => { + const reference = input.simfile.spawnfile; + const linked = reference === undefined ? undefined + : path.resolve(path.dirname(path.resolve(input.simfilePath)), reference); + if (linked !== undefined && !input.options.local) { + if (input.options.ticks !== undefined) { + throw new TypeError("Linked composed runs reject --ticks; use --local --ticks for diagnostics"); + } + for (const [key, flag] of composedForbidden) { + if (input.options[key] !== undefined) { + throw new TypeError(`Linked composed runs reject ${flag}`); + } + } + return Object.freeze({ kind: "composed", linked_spawnfile_path: linked }); + } + if (input.options.ticks === undefined) { + throw new TypeError("Local runs require --ticks"); + } + if (input.options.view) { + throw new TypeError("Local runs reject --view"); + } + return Object.freeze({ kind: "local", ...(linked === undefined + ? {} : { linked_spawnfile_path: linked }) }); +}; + diff --git a/src/compose/AGENTS.md b/src/compose/AGENTS.md new file mode 100644 index 0000000..aef82ff --- /dev/null +++ b/src/compose/AGENTS.md @@ -0,0 +1,59 @@ +# Composed Run Supervisor + +This folder owns Simfile's generic, journaled sequencing of independent world +and Spawnfile lifecycle owners. It never selects an agent, schedules or wakes a +participant, invokes a model, opens Moltnet traffic, polls agent results, or +waits for cognition. The world clock is released by topology activation and is +never coupled to agent actions. + +## Files + +- `types.ts` — phase order and provider-neutral supervisor port types. +- `json.ts` — bounded ordinary-JSON, canonical hashing, and secret-shape checks. +- `contracts.ts` — shared strict identifiers and digested receipt helpers. +- `phase.ts` — durable phase commit, lookup, and resume helpers. +- `preflight.ts` — new-run rejection of local or scripted decision inputs before lifecycle authority opens. +- `request.ts` — strict `simfile.composed-run-request.v1` parser and digest. +- `execution.ts` — durable nonsecret provider inputs and exact recovery configuration. +- `projectBinding.ts` — host-only fixture declaration seam for a runnable world, + credentials, evidence mappings, and mechanics-only replay adapter. +- `receipt.ts` — strict terminal and recovery receipt parsers/builders. +- `journal.ts` — monotonic phase journal, exact restore, and durable atomic store. +- `journalSession.ts` — pinned file identity, safe open, and expected-prior atomic replacement. +- `startup-world.ts` — prepared-resource to paused world-only readiness sequence. +- `startup-organization.ts` — organization-second startup and exact binding/readiness proof. +- `activation.ts` — topology attestation and single-use clock release. +- `supervision.ts` — world/service-only tick and terminal supervision. +- `finalize-world.ts` — pause/flush/hash/export of world evidence before cleanup. +- `finalize-organization.ts` — public Spawnfile artifact export and reconciliation. +- `cleanup.ts` — evidence-gated, receipt-owned teardown and revocation. +- `recovery.ts` — signal-safe interruption, durable recovery receipts, and resume. +- `run.ts` — the one high-level operation that composes these phase functions. +- `runRecord.ts` — the generic role-complete, exact-hash staging inventory and + atomic live-to-sealed run-directory promotion; related artifact groups are + adopted only after every member is durable. +- `replay.ts` — offline exact checkpoint/action-boundary replay through an + injected mechanics-only adapter; it has no live service or process ports. +- `liveEvidence.ts` — post-seal per-principal authenticated strategic-action + counts; these never feed mechanics, supervision, or cleanup. +- `commandReceipt.ts` — the one truthful stdout receipt derived from correlated + lifecycle, capability, Moltnet, seal, cleanup, and post-hoc evidence proofs. +- `viewer.ts` — optional observer-only live-to-sealed viewer attachment; all + extension/server failures remain outside lifecycle and mechanics authority, + and a bounded observer wait acknowledges seal reconciliation before close. +- `viewerBinding.ts` — optional host-only mapping from trusted viewer-extension + ids to recorded presentation artifacts and one bounded public live trace. +- `liveViewerProjection.ts` — observer-only mirroring of verified public trace + bytes into the live frame transport; failed publications roll back, while + exact snapshots plus canonical request/response evidence are sealed with the + derived frame track and provenance ledger as one artifact group. +- `index.ts` — named public barrel. + +Tests remain beside the boundary they prove. Production files stay below 400 +lines. All journal and receipt values are secret-free, versioned, correlated to +one run, and additive only through a new versioned contract. + +`lifecycle.test-helper.ts` supplies only neutral, secret-free lifecycle fixtures +for the colocated phase and end-to-end tests; it is excluded from production. +`run.test-helper.ts` supplies the isolated zero-agent target used by composed-run +and recovery integration tests; it is likewise excluded from production. diff --git a/src/sims/CLAUDE.md b/src/compose/CLAUDE.md similarity index 100% rename from src/sims/CLAUDE.md rename to src/compose/CLAUDE.md diff --git a/src/compose/activation.test.ts b/src/compose/activation.test.ts new file mode 100644 index 0000000..dc5503e --- /dev/null +++ b/src/compose/activation.test.ts @@ -0,0 +1,257 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + activateComposedTopology, + createComposedTopologyActivationReceipt, + createComposedTopologyAttestationReceipt, + createComposedWorldTickReceipt, + type ComposedTopologyActivationPort, + type ComposedTopologyExpectation, +} from "./activation.js"; +import { digestComposedJson } from "./json.js"; +import type { ComposedPhaseJournal } from "./journal.js"; +import { + lifecycleDigest, + lifecycleHandle, + lifecyclePhaseContext, + lifecycleRequest, + organizationReadyLifecycleJournal, + worldReadyLifecycleJournal, +} from "./lifecycle.test-helper.js"; +import { composedRunPhaseIndex } from "./types.js"; + +const expectation = (): ComposedTopologyExpectation => ({ + selected_target: { + fingerprint: `sha256:${"1".repeat(32)}`, + handle: lifecycleHandle("6"), + }, + topology_request_digest: lifecycleDigest("7"), +}); + +const phaseDigest = ( + journal: ComposedPhaseJournal, + phase: "world_ready" | "organization_ready", +): string => journal.entries[composedRunPhaseIndex(phase)]!.payload_digest; + +const targetTopology = ( + journal: ComposedPhaseJournal, + expected = expectation(), +) => { + const body = { + descriptor_digest: journal.request.descriptor_digest, + handoff_scope: "organization_to_private_service" as const, + organization: { + data_network_attachment: "exact" as const, + egress_policy: "egress_only" as const, + }, + request_digest: expected.topology_request_digest ?? lifecycleDigest("7"), + run_id: journal.request.run_id, + selected_target: expected.selected_target, + service_discovery: "dns_only" as const, + version: "spawnfile.target-topology-receipt.v1" as const, + world_network: "private_internal" as const, + world_service: { + data_network_attachment: "exactly_one" as const, + egress_policy: "none" as const, + published_ports: "none" as const, + }, + }; + return { + ...body, + receipt_digest: digestComposedJson("spawnfile.target-topology-receipt.v1", body), + }; +}; + +type Mutation = Readonly<{ + activation?: (value: unknown) => unknown; + attestation?: (value: unknown) => unknown; + tick?: (value: unknown) => unknown; +}>; + +const fakePort = ( + journal: ComposedPhaseJournal, + expected = expectation(), + mutation: Mutation = {}, +) => { + const calls = { activate: 0, attest: 0, published: 0, tick: 0 }; + let activated = false; + const publicationKeys = new Set(); + const port: ComposedTopologyActivationPort = { + activateTopology: async ({ attestation, idempotency_key }) => { + calls.activate += 1; + if (!publicationKeys.has(idempotency_key)) { + publicationKeys.add(idempotency_key); + calls.published += 1; + activated = true; + } + const topology = attestation.target_topology; + const marker = { + bundle_digest: journal.request.world.artifact_manifest_digest, + run_id: journal.request.run_id, + state: "activated" as const, + topology_receipt_digest: topology.receipt_digest, + topology_request_digest: topology.request_digest, + version: "spawnfile.world-service-activation.v1" as const, + }; + const activationBody = { + activation_digest: digestComposedJson("spawnfile.world-service-activation.v1", marker), + bundle_digest: marker.bundle_digest, + run_id: marker.run_id, + state: marker.state, + topology_receipt_digest: marker.topology_receipt_digest, + topology_request_digest: marker.topology_request_digest, + version: "spawnfile.target-topology-activation-receipt.v1" as const, + }; + const target = { + ...activationBody, + receipt_digest: digestComposedJson( + "spawnfile.target-topology-activation-receipt.v1", activationBody, + ), + }; + const receipt = createComposedTopologyActivationReceipt({ + attestation_receipt_digest: attestation.receipt_digest, + run_id: journal.request.run_id, + target_activation: target, + }); + return mutation.activation?.(receipt) ?? receipt; + }, + attestTopology: async (input) => { + calls.attest += 1; + const receipt = createComposedTopologyAttestationReceipt({ + organization_phase_digest: input.organization_phase_digest, + request_digest: input.request_digest, + run_id: input.run_id, + target_topology: targetTopology(journal, expected), + world_phase_digest: input.world_phase_digest, + }); + return mutation.attestation?.(receipt) ?? receipt; + }, + readFirstTick: async ({ activation }) => { + calls.tick += 1; + if (!activated) throw new Error("clock remains paused before activation"); + const receipt = createComposedWorldTickReceipt({ + activation_receipt_digest: activation.receipt_digest, + clock: { completed_tick: 1, next_tick: 2, state: "running" }, + run_id: journal.request.run_id, + world_phase_digest: phaseDigest(journal, "world_ready"), + }); + return mutation.tick?.(receipt) ?? receipt; + }, + }; + return { + attemptClaim: () => { + if (!activated) throw new Error("claim authority is inactive"); + }, + calls, + port, + }; +}; + +test("activation is atomic and tick 1 needs no participant action", async () => { + const request = lifecycleRequest(); + const initial = organizationReadyLifecycleJournal(request); + const fake = fakePort(initial); + const participantActions = 0; + assert.throws(fake.attemptClaim, /inactive/u); + const journal = await activateComposedTopology({ + context: lifecyclePhaseContext().context, + expectation: expectation(), + journal: initial, + port: fake.port, + }); + assert.equal(journal.current_phase, "tick_1"); + assert.equal(participantActions, 0); + assert.deepEqual(fake.calls, { activate: 1, attest: 1, published: 1, tick: 1 }); +}); + +test("activation resumes every durable boundary without republishing", async () => { + for (const failedPhase of ["topology_verified", "activated", "tick_1"] as const) { + const request = lifecycleRequest({ run_id: `run-${failedPhase}` }); + const initial = organizationReadyLifecycleJournal(request); + const fake = fakePort(initial); + const persisted: ComposedPhaseJournal[] = []; + await assert.rejects(activateComposedTopology({ + context: lifecyclePhaseContext({ + afterPhase: (phase) => { + if (phase === failedPhase) throw new Error(`fault after ${phase}`); + }, + persisted, + }).context, + expectation: expectation(), + journal: initial, + port: fake.port, + }), /fault after/u); + const resumed = await activateComposedTopology({ + context: lifecyclePhaseContext().context, + expectation: expectation(), + journal: persisted.at(-1), + port: fake.port, + }); + assert.equal(resumed.current_phase, "tick_1"); + assert.deepEqual(fake.calls, { activate: 1, attest: 1, published: 1, tick: 1 }); + } +}); + +test("activation rejects missing owner, stale, cross-run, partial, and forged proofs", async () => { + const request = lifecycleRequest(); + const initial = organizationReadyLifecycleJournal(request); + const run = (mutation: Mutation, expected = expectation()) => activateComposedTopology({ + context: lifecyclePhaseContext().context, + expectation: expected, + journal: initial, + port: fakePort(initial, expectation(), mutation).port, + }); + await assert.rejects(activateComposedTopology({ + context: lifecyclePhaseContext().context, + expectation: expectation(), + journal: worldReadyLifecycleJournal(request), + port: fakePort(initial).port, + }), /both owners/u); + await assert.rejects(run({}, { + ...expectation(), topology_request_digest: lifecycleDigest("8"), + }), /correlation/u); + await assert.rejects(run({ attestation: (raw) => { + const { receipt_digest: _receiptDigest, ...body } = raw as ReturnType< + typeof createComposedTopologyAttestationReceipt + >; + const forged = { ...body, world_phase_digest: lifecycleDigest("8") }; + return { + ...forged, + receipt_digest: digestComposedJson("simfile.composed-topology-attestation.v1", forged), + }; + } }), /correlation/u); + await assert.rejects(run({ attestation: (raw) => ({ + ...(raw as Record), run_id: "run-foreign", + }) }), /digest|correlation/u); + await assert.rejects(run({ activation: () => ({ state: "activated" }) }), /expected|invalid/u); + await assert.rejects(run({ activation: (raw) => ({ + ...(raw as Record), receipt_digest: lifecycleDigest("9"), + }) }), /digest/u); + await assert.rejects(run({ tick: (raw) => ({ + ...(raw as Record), clock: { completed_tick: 2, next_tick: 3, state: "running" }, + }) }), /expected|digest/u); +}); + +test("live activation accepts only a request carrying the declared claim extension", async () => { + const request = lifecycleRequest({ + mode: "live", + required_world_capabilities: ["simfile.world-decision-claim.v1"], + }); + const initial = organizationReadyLifecycleJournal(request); + const journal = await activateComposedTopology({ + context: lifecyclePhaseContext().context, + expectation: expectation(), + journal: initial, + port: fakePort(initial).port, + }); + assert.equal(journal.current_phase, "tick_1"); + assert.throws(() => lifecycleRequest({ mode: "live" }), /decision-claim/u); + const unattested = organizationReadyLifecycleJournal(request, false); + await assert.rejects(activateComposedTopology({ + context: lifecyclePhaseContext().context, + expectation: expectation(), + journal: unattested, + port: fakePort(unattested).port, + }), /world-attested capabilities/u); +}); diff --git a/src/compose/activation.ts b/src/compose/activation.ts new file mode 100644 index 0000000..d2df0d9 --- /dev/null +++ b/src/compose/activation.ts @@ -0,0 +1,318 @@ +import { z } from "zod"; + +import { + composedDigestSchema, + composedHandleSchema, + composedRunIdSchema, + parseComposedDigestedContract, + sealComposedContract, +} from "./contracts.js"; +import { digestComposedJson } from "./json.js"; +import { parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; +import { + commitComposedPhase, + composedPhasePayload, + composedPhaseReached, + type ComposedPhaseContext, +} from "./phase.js"; +import { composedRunPhaseIndex } from "./types.js"; +import { parseWorldSidecarReadiness } from "../world-artifact/readiness.js"; + +export const COMPOSED_TOPOLOGY_ATTESTATION_VERSION = + "simfile.composed-topology-attestation.v1" as const; +export const COMPOSED_TOPOLOGY_ACTIVATION_VERSION = + "simfile.composed-topology-activation.v1" as const; +export const COMPOSED_WORLD_TICK_VERSION = "simfile.composed-world-tick.v1" as const; + +const selectedTarget = z.object({ + fingerprint: z.string().regex(/^sha256:[a-f0-9]{32}$/u), + handle: composedHandleSchema, +}).strict(); +const targetTopology = z.object({ + descriptor_digest: composedDigestSchema, + handoff_scope: z.literal("organization_to_private_service"), + organization: z.object({ + data_network_attachment: z.literal("exact"), + egress_policy: z.literal("egress_only"), + }).strict(), + receipt_digest: composedDigestSchema, + request_digest: composedDigestSchema, + run_id: composedRunIdSchema, + selected_target: selectedTarget, + service_discovery: z.literal("dns_only"), + version: z.literal("spawnfile.target-topology-receipt.v1"), + world_network: z.literal("private_internal"), + world_service: z.object({ + data_network_attachment: z.literal("exactly_one"), + egress_policy: z.literal("none"), + published_ports: z.literal("none"), + }).strict(), +}).strict(); +const targetActivation = z.object({ + activation_digest: composedDigestSchema, + bundle_digest: composedDigestSchema, + receipt_digest: composedDigestSchema, + run_id: composedRunIdSchema, + state: z.literal("activated"), + topology_receipt_digest: composedDigestSchema, + topology_request_digest: composedDigestSchema, + version: z.literal("spawnfile.target-topology-activation-receipt.v1"), +}).strict(); + +const attestationSchema = z.object({ + organization_phase_digest: composedDigestSchema, + receipt_digest: composedDigestSchema, + request_digest: composedDigestSchema, + run_id: composedRunIdSchema, + target_topology: targetTopology, + version: z.literal(COMPOSED_TOPOLOGY_ATTESTATION_VERSION), + world_phase_digest: composedDigestSchema, +}).strict(); +const activationSchema = z.object({ + attestation_receipt_digest: composedDigestSchema, + receipt_digest: composedDigestSchema, + run_id: composedRunIdSchema, + target_activation: targetActivation, + version: z.literal(COMPOSED_TOPOLOGY_ACTIVATION_VERSION), +}).strict(); +const tickSchema = z.object({ + activation_receipt_digest: composedDigestSchema, + clock: z.object({ + completed_tick: z.number().int().min(1).max(1_000_000_000), + next_tick: z.number().int().min(2).max(1_000_000_001), + state: z.literal("running"), + }).strict().superRefine((value, context) => { + if (value.next_tick !== value.completed_tick + 1) context.addIssue({ + code: z.ZodIssueCode.custom, message: "composed world tick frontier is invalid", + }); + }), + receipt_digest: composedDigestSchema, + run_id: composedRunIdSchema, + version: z.literal(COMPOSED_WORLD_TICK_VERSION), + world_phase_digest: composedDigestSchema, +}).strict(); + +export type ComposedTopologyAttestationReceipt = z.infer; +export type ComposedTopologyActivationReceipt = z.infer; +export type ComposedWorldTickReceipt = z.infer; +export type TargetTopologyReceipt = z.infer; +export type TargetTopologyActivationReceipt = z.infer; + +const verifyTargetTopology = (value: TargetTopologyReceipt): void => { + const { receipt_digest: _receiptDigest, ...body } = value; + if (value.receipt_digest !== digestComposedJson("spawnfile.target-topology-receipt.v1", body)) { + throw new TypeError("target topology receipt digest is invalid"); + } +}; + +const verifyTargetActivation = (value: TargetTopologyActivationReceipt): void => { + const { receipt_digest: _receiptDigest, ...body } = value; + if (value.receipt_digest !== digestComposedJson( + "spawnfile.target-topology-activation-receipt.v1", body, + )) throw new TypeError("target topology activation receipt digest is invalid"); + const marker = { + bundle_digest: value.bundle_digest, + run_id: value.run_id, + state: value.state, + topology_receipt_digest: value.topology_receipt_digest, + topology_request_digest: value.topology_request_digest, + version: "spawnfile.world-service-activation.v1", + }; + if (value.activation_digest !== digestComposedJson( + "spawnfile.world-service-activation.v1", marker, + )) throw new TypeError("target world activation digest is invalid"); +}; + +export const parseComposedTopologyAttestationReceipt = ( + raw: unknown, +): ComposedTopologyAttestationReceipt => { + const value = parseComposedDigestedContract(raw, attestationSchema, + COMPOSED_TOPOLOGY_ATTESTATION_VERSION, "composed topology attestation receipt"); + verifyTargetTopology(value.target_topology); + return value; +}; +export const createComposedTopologyAttestationReceipt = ( + body: Omit, +): ComposedTopologyAttestationReceipt => parseComposedTopologyAttestationReceipt( + sealComposedContract(COMPOSED_TOPOLOGY_ATTESTATION_VERSION, { + ...body, version: COMPOSED_TOPOLOGY_ATTESTATION_VERSION, + }), +); +export const parseComposedTopologyActivationReceipt = ( + raw: unknown, +): ComposedTopologyActivationReceipt => { + const value = parseComposedDigestedContract(raw, activationSchema, + COMPOSED_TOPOLOGY_ACTIVATION_VERSION, "composed topology activation receipt"); + verifyTargetActivation(value.target_activation); + return value; +}; +export const createComposedTopologyActivationReceipt = ( + body: Omit, +): ComposedTopologyActivationReceipt => parseComposedTopologyActivationReceipt( + sealComposedContract(COMPOSED_TOPOLOGY_ACTIVATION_VERSION, { + ...body, version: COMPOSED_TOPOLOGY_ACTIVATION_VERSION, + }), +); +export const parseComposedWorldTickReceipt = (raw: unknown): ComposedWorldTickReceipt => + parseComposedDigestedContract(raw, tickSchema, + COMPOSED_WORLD_TICK_VERSION, "composed world tick receipt"); +export const createComposedWorldTickReceipt = ( + body: Omit, +): ComposedWorldTickReceipt => parseComposedWorldTickReceipt( + sealComposedContract(COMPOSED_WORLD_TICK_VERSION, { + ...body, version: COMPOSED_WORLD_TICK_VERSION, + }), +); + +export interface ComposedTopologyExpectation { + readonly selected_target: Readonly<{ fingerprint: string; handle: string }>; + readonly topology_request_digest?: string; +} + +export interface ComposedTopologyActivationPort { + attestTopology(input: Readonly<{ + organization_phase: Readonly>; + organization_phase_digest: string; + request_digest: string; + run_id: string; + topology_request_digest: string; + signal: AbortSignal; + world_phase: Readonly>; + world_phase_digest: string; + }>): Promise; + activateTopology(input: Readonly<{ + attestation: ComposedTopologyAttestationReceipt; + idempotency_key: string; + signal: AbortSignal; + }>): Promise; + readFirstTick(input: Readonly<{ + activation: ComposedTopologyActivationReceipt; + signal: AbortSignal; + }>): Promise; +} + +const phaseDigest = (journal: ComposedPhaseJournal, phase: "world_ready" | "organization_ready") => + journal.entries[composedRunPhaseIndex(phase)]!.payload_digest; +const operationKey = (journal: ComposedPhaseJournal): string => + `idem_${digestComposedJson("simfile.composed-topology-operation.v1", { + operation: "activate_topology", request_digest: journal.request_digest, + }).slice(7, 39)}`; +const sameTarget = (left: TargetTopologyReceipt["selected_target"], right: ComposedTopologyExpectation["selected_target"]): boolean => + left.fingerprint === right.fingerprint && left.handle === right.handle; + +const verifyAttestation = ( + raw: unknown, + journal: ComposedPhaseJournal, + expectation: ComposedTopologyExpectation, +): ComposedTopologyAttestationReceipt => { + const receipt = parseComposedTopologyAttestationReceipt(raw); + const topology = receipt.target_topology; + if (receipt.run_id !== journal.request.run_id + || receipt.request_digest !== journal.request_digest + || receipt.world_phase_digest !== phaseDigest(journal, "world_ready") + || receipt.organization_phase_digest !== phaseDigest(journal, "organization_ready") + || topology.run_id !== journal.request.run_id + || topology.descriptor_digest !== journal.request.descriptor_digest + || (expectation.topology_request_digest !== undefined + && topology.request_digest !== expectation.topology_request_digest) + || !sameTarget(topology.selected_target, expectation.selected_target)) { + throw new TypeError("composed topology attestation correlation is invalid"); + } + return receipt; +}; + +const verifyActivation = ( + raw: unknown, + journal: ComposedPhaseJournal, + attestation: ComposedTopologyAttestationReceipt, +): ComposedTopologyActivationReceipt => { + const receipt = parseComposedTopologyActivationReceipt(raw); + const activation = receipt.target_activation; + if (receipt.run_id !== journal.request.run_id + || receipt.attestation_receipt_digest !== attestation.receipt_digest + || activation.run_id !== journal.request.run_id + || activation.bundle_digest !== journal.request.world.artifact_manifest_digest + || activation.topology_request_digest !== attestation.target_topology.request_digest + || activation.topology_receipt_digest !== attestation.target_topology.receipt_digest) { + throw new TypeError("composed topology activation correlation is invalid"); + } + return receipt; +}; + +/** Attests both owners, publishes one activation, then observes clock tick 1. */ +export const activateComposedTopology = async (input: Readonly<{ + context: ComposedPhaseContext; + expectation: ComposedTopologyExpectation; + journal: unknown; + port: ComposedTopologyActivationPort; + signal?: AbortSignal; +}>): Promise => { + let journal = parseComposedPhaseJournal(input.journal); + if (!composedPhaseReached(journal, "organization_ready")) { + throw new TypeError("composed activation requires both owners ready"); + } + const readiness = parseWorldSidecarReadiness( + composedPhasePayload(journal, "world_ready").readiness, + ); + const advertised = new Map((readiness.capabilities ?? []).map((entry) => [ + entry.identity, entry.manifest_digest, + ])); + if (journal.request.required_world_capabilities.some((identity) => { + const manifest = advertised.get(identity); + return manifest === undefined || !readiness.capability_manifest_digests.includes(manifest); + })) { + throw new TypeError("composed activation requires world-attested capabilities"); + } + if (!composedPhaseReached(journal, "topology_verified")) { + const worldPhase = composedPhasePayload(journal, "world_ready"); + const organizationPhase = composedPhasePayload(journal, "organization_ready"); + const topologyRequestDigest = input.expectation.topology_request_digest + ?? digestComposedJson("simfile.composed-topology-request-hint.v1", { + organization_phase_digest: phaseDigest(journal, "organization_ready"), + request_digest: journal.request_digest, + world_phase_digest: phaseDigest(journal, "world_ready"), + }); + const receipt = verifyAttestation(await input.port.attestTopology({ + organization_phase: organizationPhase, + organization_phase_digest: phaseDigest(journal, "organization_ready"), + request_digest: journal.request_digest, + run_id: journal.request.run_id, + topology_request_digest: topologyRequestDigest, + signal: input.signal ?? new AbortController().signal, + world_phase: worldPhase, + world_phase_digest: phaseDigest(journal, "world_ready"), + }), journal, input.expectation); + journal = await commitComposedPhase(journal, "topology_verified", { + attestation: receipt, receipt_digest: receipt.receipt_digest, run_id: journal.request.run_id, + }, input.context); + } + const attestation = verifyAttestation( + composedPhasePayload(journal, "topology_verified").attestation, journal, input.expectation, + ); + if (!composedPhaseReached(journal, "activated")) { + const receipt = verifyActivation(await input.port.activateTopology({ + attestation, idempotency_key: operationKey(journal), + signal: input.signal ?? new AbortController().signal, + }), journal, attestation); + journal = await commitComposedPhase(journal, "activated", { + activation: receipt, receipt_digest: receipt.receipt_digest, run_id: journal.request.run_id, + }, input.context); + } + const activation = verifyActivation( + composedPhasePayload(journal, "activated").activation, journal, attestation, + ); + if (!composedPhaseReached(journal, "tick_1")) { + const tick = parseComposedWorldTickReceipt(await input.port.readFirstTick({ + activation, signal: input.signal ?? new AbortController().signal, + })); + if (tick.run_id !== journal.request.run_id + || tick.activation_receipt_digest !== activation.receipt_digest + || tick.world_phase_digest !== phaseDigest(journal, "world_ready")) { + throw new TypeError("composed first tick correlation is invalid"); + } + journal = await commitComposedPhase(journal, "tick_1", { + receipt: tick, receipt_digest: tick.receipt_digest, run_id: journal.request.run_id, + }, input.context); + } + return journal; +}; diff --git a/src/compose/cleanup.test.ts b/src/compose/cleanup.test.ts new file mode 100644 index 0000000..ffc090d --- /dev/null +++ b/src/compose/cleanup.test.ts @@ -0,0 +1,258 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + cleanupComposedRun, + ComposedCleanupError, + createComposedCleanupOperationReceipt, + parseComposedCleanupReceipt, + type ComposedCleanupOperation, + type ComposedCleanupPort, +} from "./cleanup.js"; +import { finalizeComposedOrganization } from "./finalize-organization.js"; +import { + createComposedWorldEvidenceReceipt, + createComposedWorldPauseReceipt, +} from "./finalize-world.js"; +import { appendComposedPhase, type ComposedPhaseJournal } from "./journal.js"; +import { + lifecycleDigest, + lifecycleHandle, + lifecyclePhaseContext, + lifecycleRequest, + terminalLifecycleJournal, + worldEvidenceLifecycleJournal, +} from "./lifecycle.test-helper.js"; +import { composedPhasePayload } from "./phase.js"; +import { parseComposedWorldServiceReceipt } from "./startup-world.js"; +import { parseComposedWorldTerminalReceipt } from "./supervision.js"; + +const organizationExport = (runId: string) => ({ + deployment: "organization-unit", + failed_files: [], + index: { + deployment: "organization-unit", + exported_at: "2026-01-01T00:00:14.000Z", + files: [ + { bytes: 1, path: "raw/daimon/member/log.jsonl", sha256: "a".repeat(64), source: { kind: "volume", ref: "d:/log" } }, + { bytes: 1, path: "raw/mneme/bank/log.jsonl", sha256: "b".repeat(64), source: { kind: "volume", ref: "m:/log" } }, + { bytes: 1, path: "raw/moltnet/log.jsonl", sha256: "c".repeat(64), source: { kind: "volume", ref: "n:/log" } }, + ], + run_id: runId, + version: "spawnfile.export-index.v1", + }, + index_path: "/evidence/spawnfile/export-index.json", + missing_optional_files: [], +}); + +const evidenceCompleteJournal = async (request = lifecycleRequest()) => + finalizeComposedOrganization({ + context: lifecyclePhaseContext().context, + deployment_name: "organization-unit", + journal: worldEvidenceLifecycleJournal(request), + port: { exportOrganizationEvidence: async () => organizationExport(request.run_id) }, + }); + +const fakePort = (input: Readonly<{ + foreignAt?: ComposedCleanupOperation; + partialAt?: ComposedCleanupOperation; +}> = {}) => { + const calls: Array<{ + operation: ComposedCleanupOperation; + owned_handles: readonly string[]; + target_handles: readonly string[]; + }> = []; + const sideEffects = new Set(); + const port: ComposedCleanupPort = { + performCleanupOperation: async (request) => { + calls.push({ + operation: request.operation, + owned_handles: request.owned_handles, + target_handles: request.target_handles, + }); + sideEffects.add(request.idempotency_key); + if (input.foreignAt === request.operation) { + const foreign = lifecycleHandle("z"); + return createComposedCleanupOperationReceipt({ + operation: request.operation, + ownership_digest: request.ownership_digest, + released_handles: [foreign], + remaining_owned_handles: [...request.owned_handles], + run_id: request.run_id, + state: "completed", + target_handles: [...request.target_handles, foreign].sort(), + }); + } + const releasable = request.operation === "stop_world" ? [] : [...request.target_handles]; + const released = input.partialAt === request.operation ? releasable.slice(0, 1) : releasable; + const remaining = request.owned_handles.filter((handle) => !released.includes(handle)); + return createComposedCleanupOperationReceipt({ + operation: request.operation, + ownership_digest: request.ownership_digest, + released_handles: [...released].sort(), + remaining_owned_handles: [...remaining].sort(), + run_id: request.run_id, + state: input.partialAt === request.operation ? "incomplete" : "completed", + target_handles: [...request.target_handles].sort(), + }); + }, + }; + return { calls, port, sideEffects }; +}; + +test("cleanup targets only receipt-owned handles and leaks nothing", async () => { + const fake = fakePort(); + const journal = await cleanupComposedRun({ + context: lifecyclePhaseContext().context, + journal: await evidenceCompleteJournal(), + port: fake.port, + }); + const receipt = parseComposedCleanupReceipt(composedPhasePayload(journal, "cleaned").receipt); + assert.equal(journal.current_phase, "cleaned"); + assert.deepEqual(receipt.remaining_owned_resources, []); + assert.deepEqual(fake.calls.map((call) => call.operation), [ + "stop_world", "detach_organization", "down_organization", + "revoke_secret_bindings", "cleanup_target_resources", + ]); + for (const call of fake.calls) { + assert.equal(call.target_handles.every((handle) => call.owned_handles.includes(handle)), true); + } + const replayed = await cleanupComposedRun({ + context: lifecyclePhaseContext().context, + journal, + port: fake.port, + }); + assert.equal(replayed.current_phase, "cleaned"); + assert.equal(fake.calls.length, 5); +}); + +test("cleanup resumes after its durable boundary without repeated side effects", async () => { + const initial = await evidenceCompleteJournal(); + const fake = fakePort(); + const persisted: ComposedPhaseJournal[] = []; + await assert.rejects(cleanupComposedRun({ + context: lifecyclePhaseContext({ + afterPhase: (phase) => { + if (phase === "cleaned") throw new Error("fault after cleaned"); + }, + persisted, + }).context, + journal: initial, + port: fake.port, + }), /fault after cleaned/u); + const resumed = await cleanupComposedRun({ + context: lifecyclePhaseContext().context, + journal: persisted.at(-1), + port: fake.port, + }); + assert.equal(resumed.current_phase, "cleaned"); + assert.equal(fake.calls.length, 5); + assert.equal(fake.sideEffects.size, 5); +}); + +test("cleanup refuses unexported or forged evidence before any owner operation", async () => { + const fake = fakePort(); + await assert.rejects(cleanupComposedRun({ + context: lifecyclePhaseContext().context, + journal: terminalLifecycleJournal(), + port: fake.port, + }), /requires both evidence exports/u); + const request = lifecycleRequest(); + const forged = appendComposedPhase(worldEvidenceLifecycleJournal(request), + "organization_evidence_exported", { + evidence: { state: "exported" }, + receipt_digest: lifecycleDigest("f"), + run_id: request.run_id, + }, "2026-01-01T00:00:14.000Z"); + await assert.rejects(cleanupComposedRun({ + context: lifecyclePhaseContext().context, + journal: forged, + port: fake.port, + })); + assert.equal(fake.calls.length, 0); +}); + +test("rehash-valid foreign world or organization evidence cannot reach cleanup", async () => { + const request = lifecycleRequest(); + const targetTerminal = terminalLifecycleJournal(request); + const service = parseComposedWorldServiceReceipt( + composedPhasePayload(targetTerminal, "world_started_paused").receipt, + ); + const terminal = parseComposedWorldTerminalReceipt( + composedPhasePayload(targetTerminal, "terminal").receipt, + ); + const pause = createComposedWorldPauseReceipt({ + final_tick: terminal.terminal_tick, + run_id: request.run_id, + service_handle: service.service_handle, + terminal_receipt_digest: terminal.receipt_digest, + }); + const paused = appendComposedPhase(targetTerminal, "world_paused", { + receipt: pause, receipt_digest: pause.receipt_digest, run_id: request.run_id, + }, "2026-01-01T00:00:12.000Z"); + const foreignWorld = createComposedWorldEvidenceReceipt({ + export_handle: lifecycleHandle("7"), + inventory: [ + { authority: "actions", bytes: 1, path: "actions/log.jsonl", sha256: lifecycleDigest("a") }, + { authority: "checkpoints", bytes: 2, path: "checkpoints/final.json", sha256: lifecycleDigest("b") }, + { authority: "projections", bytes: 3, path: "projections/world.json", sha256: lifecycleDigest("c") }, + ], + pause_receipt_digest: pause.receipt_digest, + run_id: "run-foreign", + source_service_handle: service.service_handle, + }); + const substitutedWorld = appendComposedPhase(paused, "world_evidence_exported", { + evidence: foreignWorld, + receipt_digest: foreignWorld.receipt_digest, + run_id: request.run_id, + }, "2026-01-01T00:00:13.000Z"); + const worldComplete = await finalizeComposedOrganization({ + context: lifecyclePhaseContext().context, + deployment_name: "organization-unit", + journal: substitutedWorld, + port: { exportOrganizationEvidence: async () => organizationExport(request.run_id) }, + }); + + const foreignRequest = lifecycleRequest({ run_id: "run-foreign" }); + const foreignComplete = await evidenceCompleteJournal(foreignRequest); + const foreignOrganizationPayload = composedPhasePayload( + foreignComplete, "organization_evidence_exported", + ); + const substitutedOrganization = appendComposedPhase( + worldEvidenceLifecycleJournal(request), + "organization_evidence_exported", + { ...foreignOrganizationPayload, run_id: request.run_id }, + "2026-01-01T00:00:14.000Z", + ); + + for (const journal of [worldComplete, substitutedOrganization]) { + const isolated = fakePort(); + await assert.rejects(cleanupComposedRun({ + context: lifecyclePhaseContext().context, + journal, + port: isolated.port, + }), /evidence correlation/u); + assert.equal(isolated.calls.length, 0); + } +}); + +test("foreign receipts fail closed and partial cleanup reports exact remaining ownership", async () => { + const initial = await evidenceCompleteJournal(); + const foreign = fakePort({ foreignAt: "detach_organization" }); + await assert.rejects(cleanupComposedRun({ + context: lifecyclePhaseContext().context, + journal: initial, + port: foreign.port, + }), (error: Error) => error instanceof ComposedCleanupError + && error.failed_operation === "detach_organization"); + assert.equal(foreign.calls.some((call) => call.target_handles.includes(lifecycleHandle("z"))), false); + + const partial = fakePort({ partialAt: "cleanup_target_resources" }); + await assert.rejects(cleanupComposedRun({ + context: lifecyclePhaseContext().context, + journal: initial, + port: partial.port, + }), (error: Error) => error instanceof ComposedCleanupError + && error.failed_operation === "cleanup_target_resources" + && error.remaining_owned_resources.length === 3); +}); diff --git a/src/compose/cleanup.ts b/src/compose/cleanup.ts new file mode 100644 index 0000000..c081f54 --- /dev/null +++ b/src/compose/cleanup.ts @@ -0,0 +1,315 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; + +import { parseSpawnfileComposedPreparationReceipt } from "../spawnfile/preparationReceipt.js"; +import { + composedDigestSchema, + composedHandleSchema, + composedIdentifierSchema, + composedRunIdSchema, + parseComposedDigestedContract, + sealComposedContract, +} from "./contracts.js"; +import { parseComposedOrganizationEvidenceReceipt } from "./finalize-organization.js"; +import { + parseComposedWorldEvidenceReceipt, + parseComposedWorldPauseReceipt, +} from "./finalize-world.js"; +import { canonicalComposedJson, digestComposedJson } from "./json.js"; +import { parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; +import { + commitComposedPhase, + composedPhasePayload, + composedPhaseReached, + type ComposedPhaseContext, +} from "./phase.js"; +import { + parseComposedWorldResourceReceipt, + parseComposedWorldServiceReceipt, +} from "./startup-world.js"; +import { parseComposedWorldTerminalReceipt } from "./supervision.js"; +import { composedRunPhaseIndex } from "./types.js"; + +export const COMPOSED_CLEANUP_OPERATION_VERSION = "simfile.composed-cleanup-operation.v1" as const; +export const COMPOSED_CLEANUP_VERSION = "simfile.composed-cleanup.v1" as const; + +const operation = z.enum([ + "stop_world", "detach_organization", "down_organization", + "revoke_secret_bindings", "cleanup_target_resources", +]); +const handles = z.array(composedHandleSchema).max(16); +const operationSchema = z.object({ + operation, + ownership_digest: composedDigestSchema, + receipt_digest: composedDigestSchema, + released_handles: handles, + remaining_owned_handles: handles, + run_id: composedRunIdSchema, + state: z.enum(["completed", "incomplete"]), + target_handles: handles, + version: z.literal(COMPOSED_CLEANUP_OPERATION_VERSION), +}).strict(); +const cleanupSchema = z.object({ + operations: z.tuple([ + operationSchema, operationSchema, operationSchema, operationSchema, operationSchema, + ]), + ownership_digest: composedDigestSchema, + receipt_digest: composedDigestSchema, + remaining_owned_resources: z.tuple([]), + run_id: composedRunIdSchema, + state: z.literal("cleaned"), + version: z.literal(COMPOSED_CLEANUP_VERSION), +}).strict(); +const organizationOwner = z.object({ + deployment: z.object({ name: composedIdentifierSchema }).passthrough(), + organization_handoff: z.object({ + network_attachment_handle: composedHandleSchema, + selected_target_receipt_digest: composedDigestSchema, + }).passthrough(), + organization_handoff_handle: composedHandleSchema, + run_id: composedRunIdSchema, + target_attachment: z.object({ + operation: z.literal("attach_organization"), + result_handle: composedHandleSchema, + }).passthrough(), +}).passthrough(); + +export type ComposedCleanupOperation = z.infer; +export type ComposedCleanupOperationReceipt = z.infer; +export type ComposedCleanupReceipt = z.infer; + +export class ComposedCleanupError extends Error { + readonly failed_operation: ComposedCleanupOperation; + readonly remaining_owned_resources: readonly string[]; + + constructor(operationName: ComposedCleanupOperation, remaining: readonly string[]) { + super("composed cleanup is incomplete"); + this.name = "ComposedCleanupError"; + this.failed_operation = operationName; + this.remaining_owned_resources = Object.freeze([...remaining].sort()); + } +} + +const sortedUnique = (values: readonly string[]): string[] => [...new Set(values)].sort(); +const exactHandles = (left: readonly string[], right: readonly string[]): boolean => + JSON.stringify(left) === JSON.stringify(right); + +export const parseComposedCleanupOperationReceipt = ( + raw: unknown, +): ComposedCleanupOperationReceipt => { + const receipt = parseComposedDigestedContract(raw, operationSchema, + COMPOSED_CLEANUP_OPERATION_VERSION, "composed cleanup operation receipt"); + const target = sortedUnique(receipt.target_handles); + const released = sortedUnique(receipt.released_handles); + const remaining = sortedUnique(receipt.remaining_owned_handles); + if (!exactHandles(target, receipt.target_handles) + || !exactHandles(released, receipt.released_handles) + || !exactHandles(remaining, receipt.remaining_owned_handles) + || released.some((handle) => !target.includes(handle))) { + throw new TypeError("composed cleanup operation handles are invalid"); + } + return receipt; +}; +export const createComposedCleanupOperationReceipt = ( + body: Omit, +): ComposedCleanupOperationReceipt => parseComposedCleanupOperationReceipt(sealComposedContract( + COMPOSED_CLEANUP_OPERATION_VERSION, + { ...body, version: COMPOSED_CLEANUP_OPERATION_VERSION }, +)); +export const parseComposedCleanupReceipt = (raw: unknown): ComposedCleanupReceipt => { + const receipt = parseComposedDigestedContract(raw, cleanupSchema, + COMPOSED_CLEANUP_VERSION, "composed cleanup receipt"); + const expected = operation.options; + if (receipt.operations.some((rawItem, index) => { + const item = parseComposedCleanupOperationReceipt(rawItem); + return item.operation !== expected[index] + || item.run_id !== receipt.run_id || item.ownership_digest !== receipt.ownership_digest + || item.state !== "completed"; + })) throw new TypeError("composed cleanup sequence is invalid"); + return receipt; +}; + +interface CleanupOwnership { + readonly data_network: string; + readonly evidence_volume: string; + readonly organization_attachment: string; + readonly organization_handoff: string; + readonly secret_bindings: string; + readonly world_resource: string; + readonly world_service: string; +} + +const deriveOwnership = (journal: ComposedPhaseJournal): Readonly => { + const prepared = parseSpawnfileComposedPreparationReceipt( + composedPhasePayload(journal, "prepared").preparation, + ); + const world = parseComposedWorldResourceReceipt( + composedPhasePayload(journal, "world_created").receipt, + ); + const service = parseComposedWorldServiceReceipt( + composedPhasePayload(journal, "world_started_paused").receipt, + ); + const organization = organizationOwner.parse( + composedPhasePayload(journal, "organization_started").up_receipt, + ); + const selectedDigest = `sha256:${createHash("sha256") + .update(canonicalComposedJson(prepared.selected_target), "utf8").digest("hex")}`; + if (prepared.run_id !== journal.request.run_id + || prepared.receipt_digest !== composedPhasePayload(journal, "prepared").preparation_receipt_digest + || prepared.descriptor_digest !== journal.request.descriptor_digest + || prepared.organization.artifact_digest !== journal.request.organization.artifact_digest + || prepared.organization.world_bindings_digest !== journal.request.organization.world_bindings_digest + || prepared.world.artifact_manifest_digest !== journal.request.world.artifact_manifest_digest + || prepared.world.bundle_digest !== journal.request.world.bundle_digest + || prepared.target_selector !== journal.request.target.selector + || organization.run_id !== journal.request.run_id + || organization.organization_handoff.selected_target_receipt_digest !== selectedDigest) { + throw new TypeError("composed cleanup ownership correlation is invalid"); + } + const ownership = Object.freeze({ + data_network: prepared.resources.data_network.result_handle, + evidence_volume: prepared.resources.evidence_volume.result_handle, + organization_attachment: organization.target_attachment.result_handle, + organization_handoff: organization.organization_handoff_handle, + secret_bindings: prepared.resources.secret_bindings.result_handle, + world_resource: world.resource_handle, + world_service: service.service_handle, + }); + if (new Set(Object.values(ownership)).size !== Object.keys(ownership).length) { + throw new TypeError("composed cleanup ownership handles are not unique"); + } + return ownership; +}; + +const verifyEvidenceCorrelation = (journal: ComposedPhaseJournal): void => { + const worldPayload = composedPhasePayload(journal, "world_evidence_exported"); + const organizationPayload = composedPhasePayload(journal, "organization_evidence_exported"); + const world = parseComposedWorldEvidenceReceipt(worldPayload.evidence); + const organization = parseComposedOrganizationEvidenceReceipt(organizationPayload.evidence); + const pause = parseComposedWorldPauseReceipt( + composedPhasePayload(journal, "world_paused").receipt, + ); + const terminal = parseComposedWorldTerminalReceipt( + composedPhasePayload(journal, "terminal").receipt, + ); + const service = parseComposedWorldServiceReceipt( + composedPhasePayload(journal, "world_started_paused").receipt, + ); + const owner = organizationOwner.parse( + composedPhasePayload(journal, "organization_started").up_receipt, + ); + if (world.run_id !== journal.request.run_id + || worldPayload.receipt_digest !== world.receipt_digest + || world.pause_receipt_digest !== pause.receipt_digest + || world.source_service_handle !== service.service_handle + || pause.run_id !== journal.request.run_id + || pause.service_handle !== service.service_handle + || pause.terminal_receipt_digest !== terminal.receipt_digest + || pause.final_tick !== terminal.terminal_tick + || terminal.run_id !== journal.request.run_id + || service.run_id !== journal.request.run_id + || organization.run_id !== journal.request.run_id + || organizationPayload.receipt_digest !== organization.receipt_digest + || organization.deployment !== owner.deployment.name + || organization.organization_phase_digest + !== journal.entries[composedRunPhaseIndex("organization_ready")]!.payload_digest) { + throw new TypeError("composed evidence correlation is invalid"); + } +}; + +export interface ComposedCleanupPort { + performCleanupOperation(input: Readonly<{ + idempotency_key: string; + operation: ComposedCleanupOperation; + owned_handles: readonly string[]; + ownership_digest: string; + run_id: string; + signal: AbortSignal; + target_handles: readonly string[]; + }>): Promise; +} + +const targetsFor = (ownership: CleanupOwnership): Readonly> => ({ + cleanup_target_resources: sortedUnique([ + ownership.data_network, ownership.evidence_volume, + ownership.world_resource, ownership.world_service, + ]), + detach_organization: [ownership.organization_attachment], + down_organization: [ownership.organization_handoff], + revoke_secret_bindings: [ownership.secret_bindings], + stop_world: [ownership.world_service], +}); +const operationKey = (journal: ComposedPhaseJournal, name: string): string => + `idem_${digestComposedJson("simfile.composed-cleanup-operation-key.v1", { + operation: name, request_digest: journal.request_digest, + }).slice(7, 39)}`; + +/** Cleans only handles derived from verified lifecycle receipts after both exports. */ +export const cleanupComposedRun = async (input: Readonly<{ + context: ComposedPhaseContext; + journal: unknown; + port: ComposedCleanupPort; + signal?: AbortSignal; +}>): Promise => { + let journal = parseComposedPhaseJournal(input.journal); + if (!composedPhaseReached(journal, "organization_evidence_exported")) { + throw new TypeError("composed cleanup requires both evidence exports"); + } + verifyEvidenceCorrelation(journal); + if (composedPhaseReached(journal, "cleaned")) { + parseComposedCleanupReceipt(composedPhasePayload(journal, "cleaned").receipt); + return journal; + } + const ownership = deriveOwnership(journal); + const ownershipDigest = digestComposedJson("simfile.composed-cleanup-ownership.v1", ownership); + const targets = targetsFor(ownership); + let remaining = sortedUnique(Object.values(ownership)); + const receipts: ComposedCleanupOperationReceipt[] = []; + for (const name of operation.options) { + const targetHandles = targets[name]; + let receipt: ComposedCleanupOperationReceipt; + try { + receipt = parseComposedCleanupOperationReceipt(await input.port.performCleanupOperation({ + idempotency_key: operationKey(journal, name), + operation: name, + owned_handles: remaining, + ownership_digest: ownershipDigest, + run_id: journal.request.run_id, + signal: input.signal ?? new AbortController().signal, + target_handles: targetHandles, + })); + } catch { + throw new ComposedCleanupError(name, remaining); + } + const releasable = name === "stop_world" ? [] : targetHandles; + const expectedRemaining = remaining.filter((handle) => !receipt.released_handles.includes(handle)); + if (receipt.run_id !== journal.request.run_id + || receipt.operation !== name + || receipt.ownership_digest !== ownershipDigest + || !exactHandles(receipt.target_handles, targetHandles) + || receipt.released_handles.some((handle) => !releasable.includes(handle)) + || !exactHandles(receipt.remaining_owned_handles, expectedRemaining)) { + throw new ComposedCleanupError(name, remaining); + } + remaining = expectedRemaining; + receipts.push(receipt); + if (receipt.state !== "completed") throw new ComposedCleanupError(name, remaining); + if (!exactHandles(receipt.released_handles, releasable)) { + throw new ComposedCleanupError(name, remaining); + } + } + if (remaining.length > 0) throw new ComposedCleanupError("cleanup_target_resources", remaining); + const receipt = parseComposedCleanupReceipt(sealComposedContract(COMPOSED_CLEANUP_VERSION, { + operations: receipts, + ownership_digest: ownershipDigest, + remaining_owned_resources: [], + run_id: journal.request.run_id, + state: "cleaned", + version: COMPOSED_CLEANUP_VERSION, + })); + journal = await commitComposedPhase(journal, "cleaned", { + receipt, receipt_digest: receipt.receipt_digest, run_id: journal.request.run_id, + }, input.context); + return journal; +}; diff --git a/src/compose/commandReceipt.test.ts b/src/compose/commandReceipt.test.ts new file mode 100644 index 0000000..c8b6e9c --- /dev/null +++ b/src/compose/commandReceipt.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { runDurableComposedRun } from "./recovery.js"; +import { lifecycleRequest } from "./lifecycle.test-helper.js"; +import { createComposedRunHarness } from "./run.test-helper.js"; +import { WORLD_DECISION_CLAIM_CAPABILITY } from "./request.js"; +import { parseComposedTerminalReceipt } from "./receipt.js"; +import { composedCommandExitCode, createComposedCommandReceipt, + parseComposedCommandReceipt, writeComposedFinalReceipt, + writeComposedProgress } from "./commandReceipt.js"; + +const completed = async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-command-receipt-")); + const request = lifecycleRequest({ mode: "live", + required_world_capabilities: [WORLD_DECISION_CLAIM_CAPABILITY] }); + const harness = createComposedRunHarness(request); + let tick = 0; + const result = await runDurableComposedRun({ configuration: harness.configuration, + journal_path: path.join(root, "journal.json"), + now: () => new Date(Date.UTC(2026, 7, 7, 12, 0, tick++)).toISOString(), + ports: harness.ports, request }); + return { journal: result.journal, receipt: parseComposedTerminalReceipt(result.receipt) }; +}; +const liveEvidence = (blue = 1) => ({ + counts: [ + { count: blue, participant: "blue", principal: "principal:blue" }, + { count: 2, participant: "red", principal: "principal:red" }, + ], + state: blue === 0 ? "failed" as const : "passed" as const, + zero_action_principals: blue === 0 ? ["principal:blue"] : [], +}); + +describe("truthful composed command receipt", () => { + it("correlates seal, cleanup, target, Moltnet, claim, counts, and viewer", async () => { + const lifecycle = await completed(); + const receipt = createComposedCommandReceipt({ journal: lifecycle.journal, + lifecycle_receipt: lifecycle.receipt, live_evidence: liveEvidence(), + manifest_digest: `sha256:${"a".repeat(64)}`, run_path: "/runs/run-lifecycle", + viewer: { state: "attached", url: "http://127.0.0.1:4400" } }); + assert.deepEqual(parseComposedCommandReceipt(receipt), receipt); + assert.equal(receipt.world_claim.identity, WORLD_DECISION_CLAIM_CAPABILITY); + assert.equal(receipt.world_claim.attested, true); + assert.equal(receipt.moltnet.capabilities[0], "pi-bridge"); + assert.equal(receipt.cleanup.remaining_owned_resources.length, 0); + assert.equal(composedCommandExitCode(receipt), 0); + }); + + it("exits nonzero for live evidence only without invalidating mechanics", async () => { + const lifecycle = await completed(); + const receipt = createComposedCommandReceipt({ journal: lifecycle.journal, + lifecycle_receipt: lifecycle.receipt, live_evidence: liveEvidence(0), + manifest_digest: `sha256:${"a".repeat(64)}`, run_path: "/runs/run-lifecycle", + viewer: { state: "disabled" } }); + assert.equal(receipt.simulation_verdict, "valid"); + assert.equal(receipt.live_agent_evidence.state, "failed"); + assert.equal(composedCommandExitCode(receipt), 1); + }); + + it("prints progress only on stderr and one JSON receipt only on stdout", async () => { + const lifecycle = await completed(); + const receipt = createComposedCommandReceipt({ journal: lifecycle.journal, + lifecycle_receipt: lifecycle.receipt, live_evidence: liveEvidence(), + manifest_digest: `sha256:${"a".repeat(64)}`, run_path: "/runs/run-lifecycle", + viewer: { state: "unavailable", error: "renderer unavailable" } }); + let stdout = ""; let stderr = ""; + const out = process.stdout.write; const error = process.stderr.write; + process.stdout.write = ((chunk: string | Uint8Array) => { stdout += chunk.toString(); return true; }) as typeof out; + process.stderr.write = ((chunk: string | Uint8Array) => { stderr += chunk.toString(); return true; }) as typeof error; + try { writeComposedProgress("world ready"); writeComposedFinalReceipt(receipt); } + finally { process.stdout.write = out; process.stderr.write = error; } + assert.equal(stderr, "world ready\n"); + assert.equal(stdout.trim().split("\n").length, 1); + assert.deepEqual(parseComposedCommandReceipt(JSON.parse(stdout) as unknown), receipt); + }); + + it("rejects unattested claims, false cleanup, and secret-shaped additions", async () => { + const lifecycle = await completed(); + assert.throws(() => parseComposedCommandReceipt({ + unexpected_token: "sk-secretsecretsecret", + }), /secret-shaped/u); + assert.throws(() => createComposedCommandReceipt({ journal: lifecycle.journal, + lifecycle_receipt: { ...lifecycle.receipt, cleanup: { + ...lifecycle.receipt.cleanup, state: "retained", + } }, live_evidence: liveEvidence(), manifest_digest: `sha256:${"a".repeat(64)}`, + run_path: "/runs/run-lifecycle", viewer: { state: "disabled" } })); + }); +}); diff --git a/src/compose/commandReceipt.ts b/src/compose/commandReceipt.ts new file mode 100644 index 0000000..a4218d1 --- /dev/null +++ b/src/compose/commandReceipt.ts @@ -0,0 +1,124 @@ +import path from "node:path"; + +import { z } from "zod"; + +import { parseWorldSidecarReadiness } from "../world-artifact/readiness.js"; +import { assertSecretFreeComposedJson, canonicalComposedJson, digestComposedJson } from "./json.js"; +import { parseComposedPhaseJournal } from "./journal.js"; +import { composedPhasePayload } from "./phase.js"; +import { parseComposedTerminalReceipt } from "./receipt.js"; +import { WORLD_DECISION_CLAIM_CAPABILITY } from "./request.js"; +import type { ComposedLiveEvidenceVerdict } from "./liveEvidence.js"; + +export const COMPOSED_COMMAND_RECEIPT_VERSION = "simfile.composed-command-receipt.v1" as const; +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const absolute = z.string().max(4_096).refine((value) => path.isAbsolute(value)); +const count = z.object({ count: z.number().int().min(0), participant: z.string().min(1), + principal: z.string().min(1) }).strict(); +const moltnet = z.object({ + architecture: z.enum(["amd64", "arm64"]), asset: z.string().min(1), asset_sha256: digest, + capabilities: z.tuple([z.literal("pi-bridge")]), release_version: z.string().min(1), + source_revision: z.string().regex(/^[a-f0-9]{40}$/u), + version: z.literal("spawnfile.moltnet-release-identity.v1"), +}).strict(); +const viewer = z.discriminatedUnion("state", [ + z.object({ state: z.literal("disabled") }).strict(), + z.object({ state: z.literal("attached"), url: z.string().url().regex(/^http:\/\/127\.0\.0\.1:/u) }).strict(), + z.object({ state: z.literal("unavailable"), error: z.string().min(1).max(4_096) }).strict(), +]); +export const composedCommandReceiptSchema = z.object({ + accepted_strategic_actions: z.array(count).min(1).max(4_096), + cleanup: z.object({ receipt_digest: digest, remaining_owned_resources: z.array(z.string()), + state: z.literal("cleaned") }).strict(), + evidence: z.record(z.string(), z.unknown()), + lifecycle_receipt_digest: digest, + live_agent_evidence: z.object({ state: z.enum(["passed", "failed"]), + zero_action_principals: z.array(z.string()) }).strict(), + manifest_digest: digest, + moltnet, + receipt_digest: digest, + run_id: z.string().min(1), + run_path: absolute, + simulation_verdict: z.literal("valid"), + status: z.literal("completed"), + target: z.record(z.string(), z.unknown()), + version: z.literal(COMPOSED_COMMAND_RECEIPT_VERSION), + viewer, + world_claim: z.object({ attested: z.literal(true), identity: z.literal(WORLD_DECISION_CLAIM_CAPABILITY), + manifest_digest: digest }).strict(), +}).strict(); +export type ComposedCommandReceipt = z.infer; + +export const parseComposedCommandReceipt = (raw: unknown): ComposedCommandReceipt => { + assertSecretFreeComposedJson(raw); + const value = composedCommandReceiptSchema.parse(raw); + const { receipt_digest: _receiptDigest, ...body } = value; + if (value.receipt_digest !== digestComposedJson(COMPOSED_COMMAND_RECEIPT_VERSION, body)) { + throw new TypeError("composed command receipt digest is invalid"); + } + const zero = value.accepted_strategic_actions.filter(({ count: valueCount }) => valueCount === 0) + .map(({ principal }) => principal); + if (JSON.stringify(zero) !== JSON.stringify(value.live_agent_evidence.zero_action_principals) + || (zero.length === 0) !== (value.live_agent_evidence.state === "passed")) { + throw new TypeError("composed command live-agent verdict is invalid"); + } + return Object.freeze(value); +}; + +/** Builds stdout truth only from the completed lifecycle, sealed record, and post-hoc counts. */ +export const createComposedCommandReceipt = (input: Readonly<{ + journal: unknown; + lifecycle_receipt: unknown; + live_evidence: ComposedLiveEvidenceVerdict; + manifest_digest: string; + run_path: string; + viewer: z.infer; +}>): ComposedCommandReceipt => { + const journal = parseComposedPhaseJournal(input.journal); + const lifecycle = parseComposedTerminalReceipt(input.lifecycle_receipt); + if (journal.current_phase !== "completed" || lifecycle.run_id !== journal.request.run_id + || lifecycle.seal.state !== "sealed" || lifecycle.cleanup.state !== "cleaned" + || lifecycle.verdict.state !== "valid") { + throw new TypeError("composed command completion proof is invalid"); + } + const organization = composedPhasePayload(journal, "organization_ready"); + const readiness = parseWorldSidecarReadiness(composedPhasePayload(journal, "world_ready").readiness); + const claim = readiness.capabilities?.find(({ identity }) => + identity === WORLD_DECISION_CLAIM_CAPABILITY); + if (claim === undefined || !readiness.capability_manifest_digests.includes(claim.manifest_digest)) { + throw new TypeError("composed command claim capability is not attested"); + } + const body = { + accepted_strategic_actions: input.live_evidence.counts, + cleanup: lifecycle.cleanup, + evidence: lifecycle.evidence, + lifecycle_receipt_digest: lifecycle.receipt_digest, + live_agent_evidence: { state: input.live_evidence.state, + zero_action_principals: input.live_evidence.zero_action_principals }, + manifest_digest: input.manifest_digest, + moltnet: moltnet.parse(organization.moltnet_release), + run_id: lifecycle.run_id, + run_path: path.resolve(input.run_path), + simulation_verdict: "valid" as const, + status: "completed" as const, + target: lifecycle.target, + version: COMPOSED_COMMAND_RECEIPT_VERSION, + viewer: input.viewer, + world_claim: { attested: true as const, identity: WORLD_DECISION_CLAIM_CAPABILITY, + manifest_digest: claim.manifest_digest }, + }; + return parseComposedCommandReceipt({ ...body, + receipt_digest: digestComposedJson(COMPOSED_COMMAND_RECEIPT_VERSION, body) }); +}; + +export const serializeComposedCommandReceipt = (receipt: ComposedCommandReceipt): string => + `${canonicalComposedJson(parseComposedCommandReceipt(receipt))}\n`; +export const composedCommandExitCode = (receipt: ComposedCommandReceipt): 0 | 1 => + parseComposedCommandReceipt(receipt).live_agent_evidence.state === "passed" ? 0 : 1; +export const writeComposedProgress = (message: string): void => { + process.stderr.write(`${message}\n`); +}; +export const writeComposedFinalReceipt = (receipt: ComposedCommandReceipt): void => { + process.stdout.write(serializeComposedCommandReceipt(receipt)); +}; + diff --git a/src/compose/composed-autonomy.test.ts b/src/compose/composed-autonomy.test.ts new file mode 100644 index 0000000..3e3af36 --- /dev/null +++ b/src/compose/composed-autonomy.test.ts @@ -0,0 +1,36 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { describe, it } from "node:test"; + +describe("composed supervision autonomy ratchet", () => { + it("contains only service/world terminal inputs", async () => { + const source = await readFile(new URL("./supervision.ts", import.meta.url), "utf8"); + for (const forbidden of [ + "pendingAgent", "pending_agent", "triggerTurn", "trigger_turn", "pollDecision", + "poll_decision", "agentReplies", "agent_replies", "minimumActionCount", + "minimum_action_count", "modelLatency", "model_latency", "send_nudge", + ]) assert.equal(source.includes(forbidden), false, forbidden); + assert.match(source, /waitForWorldTerminal/u); + assert.match(source, /operator_timeout_ms/u); + }); + + it("the production port waits only for the world-owned terminal artifact", async () => { + const source = await readFile(new URL("../spawnfile/productionPorts.ts", import.meta.url), "utf8"); + const start = source.indexOf("supervision: {"); + const end = source.indexOf("world_finalization:", start); + assert.notEqual(start, -1); + assert.notEqual(end, -1); + const supervision = source.slice(start, end); + assert.match(supervision, /waitForProductionWorldTerminal/u); + for (const forbidden of ["agent", "decision", "reply", "model", "action_count"]) { + assert.equal(supervision.includes(forbidden), false, forbidden); + } + const terminal = await readFile( + new URL("../spawnfile/productionTerminal.ts", import.meta.url), "utf8", + ); + assert.match(terminal, /terminal_artifact/u); + for (const forbidden of ["agent", "decision", "reply", "model", "action_count"]) { + assert.equal(terminal.includes(forbidden), false, forbidden); + } + }); +}); diff --git a/src/compose/contracts.ts b/src/compose/contracts.ts new file mode 100644 index 0000000..877fcde --- /dev/null +++ b/src/compose/contracts.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; + +import { assertSecretFreeComposedJson, digestComposedJson } from "./json.js"; + +export const composedDigestSchema = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +export const composedRunIdSchema = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); +export const composedHandleSchema = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); +export const composedIdentifierSchema = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); + +export const sealComposedContract = >( + version: string, + body: Value, +): Value & { receipt_digest: `sha256:${string}` } => ({ + ...body, + receipt_digest: digestComposedJson(version, body), +}); + +export const parseComposedDigestedContract = ( + raw: unknown, + schema: z.ZodType, + version: string, + label: string, +): Value => { + assertSecretFreeComposedJson(raw); + const value = schema.parse(raw); + const { receipt_digest: _receiptDigest, ...body } = value; + if (value.receipt_digest !== digestComposedJson(version, body)) { + throw new TypeError(`${label} digest is invalid`); + } + return Object.freeze(value); +}; diff --git a/src/compose/execution.ts b/src/compose/execution.ts new file mode 100644 index 0000000..3218cbc --- /dev/null +++ b/src/compose/execution.ts @@ -0,0 +1,122 @@ +import path from "node:path"; + +import { z } from "zod"; + +import { assertSecretFreeComposedJson } from "./json.js"; +import type { ComposedRunConfiguration } from "./run.js"; + +export const COMPOSED_EXECUTION_VERSION = "simfile.composed-execution.v1" as const; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const handle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); +const identifier = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); +const absolutePath = z.string().max(4_096).refine((value) => + path.isAbsolute(value) && path.normalize(value) === value && value !== path.parse(value).root); +const capability = z.object({ + identity: z.string().regex(/^[a-z][a-z0-9.-]{0,127}\.v[1-9][0-9]*$/u), + manifest_digest: digest, +}).strict(); +const selectedTarget = z.object({ + fingerprint: z.string().regex(/^sha256:[a-f0-9]{32}$/u), + handle, +}).strict(); +const processEnvironment = z.record( + z.string().regex(/^[A-Z][A-Z0-9_]{1,127}$/u), + z.string().min(1).max(4_096), +).refine((value) => Object.keys(value).length <= 32); +const readiness = z.object({ + artifact_digest: digest.nullable(), + bundle_digest: digest, + capabilities: z.array(capability).max(32).optional(), + capability_manifest_digests: z.array(digest).min(1).max(4_096), + mechanics_sha256: digest, + normalized_checkpoint_sha256: digest, + run_id: z.string().min(1).max(128), + world_instance_id: z.string().min(1).max(128), +}).strict(); +export const composedExecutionSchema = z.object({ + configuration: z.object({ + organization_expectation: z.object({ + deployment_name: identifier, + member_engines: z.record(z.string().min(1).max(128), z.string().min(1).max(128)), + moltnet_release: z.object({ + architecture: z.enum(["amd64", "arm64"]), + asset_sha256: digest, + release_version: z.string().min(1).max(128), + source_revision: z.string().regex(/^[a-f0-9]{40}$/u), + }).strict(), + selected_target_receipt_digest: digest, + unit_id: identifier, + world_binding_digest: digest, + }).strict(), + readiness_expectation: readiness, + terminal_tick: z.number().int().min(1).max(1_000_000_000), + topology_expectation: z.object({ + selected_target: selectedTarget, + topology_request_digest: digest.optional(), + }).strict(), + }).strict(), + provider: z.object({ + compiled_output_directory: absolutePath, + evidence_destination_directory: absolutePath, + evidence_mount_path: z.string().regex(/^\/(?:[A-Za-z0-9._-]+\/)*[A-Za-z0-9._-]+$/u), + lifecycle_invocations: z.object({ + down: z.string().regex(/^lci_[a-z0-9][a-z0-9_-]{15,127}$/u), + export: z.string().regex(/^lci_[a-z0-9][a-z0-9_-]{15,127}$/u), + up: z.string().regex(/^lci_[a-z0-9][a-z0-9_-]{15,127}$/u), + }).strict(), + organization_handoff: z.object({ + env_file: absolutePath, + selected_target_receipt_file: absolutePath, + world_bindings_file: absolutePath, + }).strict(), + organization_container_name: identifier, + organization_image_tag: z.string().regex(/^[a-z0-9][a-z0-9._/-]{0,127}:[a-z0-9][a-z0-9._-]{0,63}$/u), + organization_path: absolutePath, + process_environment: processEnvironment.optional(), + spawnfile_bin: absolutePath, + spawnfile_cwd: absolutePath, + target_config_producer: z.object({ + args: z.array(z.string().min(1).max(4_096)).min(1).max(32), + command: z.string().min(1).max(4_096), + transport: z.literal("stdout_to_spawnfile_stdin"), + }).strict(), + terminal_artifact: z.object({ + id: identifier, + max_bytes: z.number().int().min(1).max(131_072), + path: z.string().regex(/^\/tmp\/spawnfile-public\/[A-Za-z0-9][A-Za-z0-9._/-]*$/u), + }).strict(), + world_evidence_export: z.object({ + archive_path: absolutePath.refine((value) => path.extname(value) === ".tar"), + destination_directory: absolutePath, + }).strict().optional(), + world_readiness_port: z.number().int().min(1).max(65_535), + }).strict(), + secret_bindings: z.array(z.object({ + name: identifier, + scope: identifier, + source_handle: handle, + }).strict()).min(1).max(32), + version: z.literal(COMPOSED_EXECUTION_VERSION), +}).strict(); + +export type ComposedExecution = z.infer; + +export const parseComposedExecution = (raw: unknown): ComposedExecution => { + assertSecretFreeComposedJson(raw); + const value = composedExecutionSchema.parse(raw); + if (value.provider.target_config_producer.args.length !== 1) { + throw new TypeError("composed target config producer argv is invalid"); + } + return Object.freeze(value); +}; + +export const composedRunConfiguration = ( + execution: ComposedExecution, +): ComposedRunConfiguration => ({ + deployment_name: execution.configuration.organization_expectation.deployment_name, + organization_expectation: execution.configuration.organization_expectation, + readiness_expectation: execution.configuration.readiness_expectation, + terminal_tick: execution.configuration.terminal_tick, + topology_expectation: execution.configuration.topology_expectation, +}); diff --git a/src/compose/finalize-organization.test.ts b/src/compose/finalize-organization.test.ts new file mode 100644 index 0000000..8729639 --- /dev/null +++ b/src/compose/finalize-organization.test.ts @@ -0,0 +1,204 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + composedOrganizationExportLifecycleInvocationId, + ComposedOrganizationEvidenceError, + ORGANIZATION_EVIDENCE_RECOVERY_INSTRUCTION, + finalizeComposedOrganization, + parseComposedOrganizationEvidenceReceipt, + type ComposedOrganizationFinalizationPort, +} from "./finalize-organization.js"; +import { createComposedRunRequestDigest } from "./request.js"; +import type { ComposedPhaseJournal } from "./journal.js"; +import { + lifecyclePhaseContext, + lifecycleRequest, + terminalLifecycleJournal, + worldEvidenceLifecycleJournal, +} from "./lifecycle.test-helper.js"; +import { composedPhasePayload } from "./phase.js"; + +const exportResult = (runId: string) => ({ + deployment: "organization-unit", + failed_files: [], + index: { + deployment: "organization-unit", + exported_at: "2026-01-01T00:00:14.000Z", + files: [ + { + bytes: 10, + path: "raw/daimon/member/causal.jsonl", + sha256: "a".repeat(64), + source: { kind: "volume", ref: "daimon-volume:/causal.jsonl" }, + }, + { + bytes: 20, + path: "raw/mneme/bank/events.jsonl", + sha256: "b".repeat(64), + source: { kind: "volume", ref: "mneme-volume:/events.jsonl" }, + }, + { + bytes: 30, + path: "raw/moltnet/causal.jsonl", + sha256: "c".repeat(64), + source: { kind: "volume", ref: "moltnet-volume:/causal.jsonl" }, + }, + ], + run_id: runId, + version: "spawnfile.export-index.v1", + }, + index_path: "/run/evidence/spawnfile/export-index.json", + missing_optional_files: [], +}); + +const fakePort = ( + runId: string, + mutate: (value: ReturnType) => unknown = (value) => value, +) => { + const calls = { down: 0, requests: 0, side_effects: 0 }; + const cache = new Map(); + const source = { preserved: true }; + const port: ComposedOrganizationFinalizationPort = { + exportOrganizationEvidence: async ({ lifecycle_invocation_id }) => { + calls.requests += 1; + if (!cache.has(lifecycle_invocation_id)) { + calls.side_effects += 1; + cache.set(lifecycle_invocation_id, mutate(exportResult(runId))); + } + return cache.get(lifecycle_invocation_id); + }, + }; + return { calls, port, source }; +}; + +test("organization export lifecycle identity is request-bound", () => { + const digest = createComposedRunRequestDigest(lifecycleRequest()); + const invocation = composedOrganizationExportLifecycleInvocationId(digest); + assert.match(invocation, /^lci_[a-f0-9]{32}$/u); + assert.equal(invocation, composedOrganizationExportLifecycleInvocationId(digest)); + assert.throws(() => composedOrganizationExportLifecycleInvocationId("sha256:invalid")); +}); + +test("organization export reconciles all four evidence authorities before down", async () => { + const request = lifecycleRequest(); + const fake = fakePort(request.run_id); + const journal = await finalizeComposedOrganization({ + context: lifecyclePhaseContext().context, + deployment_name: "organization-unit", + journal: worldEvidenceLifecycleJournal(request), + port: fake.port, + }); + const evidence = parseComposedOrganizationEvidenceReceipt( + composedPhasePayload(journal, "organization_evidence_exported").evidence, + ); + assert.equal(journal.current_phase, "organization_evidence_exported"); + assert.deepEqual(evidence.authorities.map((item) => item.authority), [ + "spawnfile", "moltnet", "daimon", "mneme", + ]); + assert.deepEqual(fake.calls, { down: 0, requests: 1, side_effects: 1 }); +}); + +test("organization export admits an empty Mneme authority when no memory bank was planned", async () => { + const request = lifecycleRequest(); + const fake = fakePort(request.run_id, (value) => ({ + ...value, + index: { + ...value.index, + files: value.index.files.filter((item) => !item.path.startsWith("raw/mneme/")), + }, + })); + const journal = await finalizeComposedOrganization({ + context: lifecyclePhaseContext().context, + deployment_name: "organization-unit", + journal: worldEvidenceLifecycleJournal(request), + port: fake.port, + }); + const evidence = parseComposedOrganizationEvidenceReceipt( + composedPhasePayload(journal, "organization_evidence_exported").evidence, + ); + const mneme = evidence.authorities.find((item) => item.authority === "mneme"); + assert.ok(mneme); + assert.equal(mneme.item_count, 0); + assert.equal(mneme.missing_optional_count, 0); + assert.deepEqual(fake.calls, { down: 0, requests: 1, side_effects: 1 }); +}); + +test("organization export is exact-idempotent across pre-persist failure and phase replay", async () => { + const request = lifecycleRequest(); + const initial = worldEvidenceLifecycleJournal(request); + const fake = fakePort(request.run_id); + await assert.rejects(finalizeComposedOrganization({ + context: { + now: () => "2026-01-01T00:00:14.000Z", + persist: () => { throw new Error("persistence unavailable"); }, + }, + deployment_name: "organization-unit", + journal: initial, + port: fake.port, + }), /persistence unavailable/u); + const journal = await finalizeComposedOrganization({ + context: lifecyclePhaseContext().context, + deployment_name: "organization-unit", + journal: initial, + port: fake.port, + }); + const replayed = await finalizeComposedOrganization({ + context: lifecyclePhaseContext().context, + deployment_name: "organization-unit", + journal, + port: fake.port, + }); + assert.equal(replayed.current_phase, "organization_evidence_exported"); + assert.deepEqual(fake.calls, { down: 0, requests: 2, side_effects: 1 }); +}); + +test("missing, divergent, failed, and cross-run exports block down and remain recoverable", async () => { + const request = lifecycleRequest(); + const cases = [ + (value: ReturnType) => ({ + ...value, + index: { ...value.index, files: value.index.files.filter( + (item) => !item.path.startsWith("raw/moltnet/"), + ) }, + }), + (value: ReturnType) => ({ + ...value, + index: { ...value.index, files: value.index.files.filter( + (item) => !item.path.startsWith("raw/daimon/"), + ) }, + }), + (value: ReturnType) => ({ ...value, deployment: "other-unit" }), + (value: ReturnType) => ({ + ...value, failed_files: ["raw/moltnet/causal.jsonl"], + }), + (value: ReturnType) => ({ + ...value, index: { ...value.index, run_id: "run-foreign" }, + }), + ]; + for (const mutate of cases) { + const fake = fakePort(request.run_id, mutate); + await assert.rejects(finalizeComposedOrganization({ + context: lifecyclePhaseContext().context, + deployment_name: "organization-unit", + journal: worldEvidenceLifecycleJournal(request), + port: fake.port, + }), (error: Error) => error instanceof ComposedOrganizationEvidenceError + && error.recovery_instruction === ORGANIZATION_EVIDENCE_RECOVERY_INSTRUCTION + && error.source_preserved); + assert.deepEqual(fake.calls, { down: 0, requests: 1, side_effects: 1 }); + assert.equal(fake.source.preserved, true); + } +}); + +test("organization export refuses to run before world evidence exists", async () => { + const request = lifecycleRequest(); + const fake = fakePort(request.run_id); + await assert.rejects(finalizeComposedOrganization({ + context: lifecyclePhaseContext().context, + deployment_name: "organization-unit", + journal: terminalLifecycleJournal(request), + port: fake.port, + }), /requires world evidence/u); + assert.deepEqual(fake.calls, { down: 0, requests: 0, side_effects: 0 }); +}); diff --git a/src/compose/finalize-organization.ts b/src/compose/finalize-organization.ts new file mode 100644 index 0000000..e2f71fe --- /dev/null +++ b/src/compose/finalize-organization.ts @@ -0,0 +1,267 @@ +import { z } from "zod"; + +import { + composedDigestSchema, + composedIdentifierSchema, + composedRunIdSchema, + parseComposedDigestedContract, + sealComposedContract, +} from "./contracts.js"; +import { assertSecretFreeComposedJson, digestComposedJson } from "./json.js"; +import { parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; +import { + commitComposedPhase, + composedPhaseReached, + type ComposedPhaseContext, +} from "./phase.js"; +import { composedRunPhaseIndex } from "./types.js"; + +export const COMPOSED_ORGANIZATION_EVIDENCE_VERSION = + "simfile.composed-organization-evidence.v1" as const; +export const ORGANIZATION_EVIDENCE_RECOVERY_INSTRUCTION = + "resume the persisted composed journal at organization_evidence_exported" as const; + +const relativePath = z.string().max(512).regex( + /^(?:[A-Za-z0-9][A-Za-z0-9._-]*\/)*[A-Za-z0-9][A-Za-z0-9._-]*$/u, +); +const rawFile = z.object({ + bytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + path: relativePath, + sha256: z.string().regex(/^[a-f0-9]{64}$/u), + source: z.object({ + kind: z.enum(["container", "volume"]), + ref: z.string().min(1).max(4_096), + }).strict(), +}).strict(); +const rawResult = z.object({ + deployment: composedIdentifierSchema, + failed_files: z.array(relativePath).max(100_000), + index: z.object({ + deployment: composedIdentifierSchema, + exported_at: z.string().datetime({ offset: true }), + files: z.array(rawFile).max(100_000), + run_id: composedRunIdSchema, + version: z.literal("spawnfile.export-index.v1"), + }).strict(), + index_path: z.string().min(1).max(4_096), + missing_optional_files: z.array(relativePath).max(100_000), +}).passthrough(); +const authority = z.enum(["spawnfile", "moltnet", "daimon", "mneme"]); +const evidenceFile = z.object({ + authority: authority.exclude(["spawnfile"]), + bytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + path: relativePath, + sha256: composedDigestSchema, +}).strict(); +const authorityEntry = z.object({ + authority, + digest: composedDigestSchema, + item_count: z.number().int().min(0).max(100_000), + missing_optional_count: z.number().int().min(0).max(100_000), +}).strict(); +const evidenceSchema = z.object({ + authorities: z.tuple([authorityEntry, authorityEntry, authorityEntry, authorityEntry]), + deployment: composedIdentifierSchema, + export_index_digest: composedDigestSchema, + files: z.array(evidenceFile).max(100_000), + inventory_digest: composedDigestSchema, + missing_optional_files: z.array(relativePath).max(100_000), + organization_phase_digest: composedDigestSchema, + receipt_digest: composedDigestSchema, + run_id: composedRunIdSchema, + source_state: z.literal("preserved"), + state: z.literal("exported"), + version: z.literal(COMPOSED_ORGANIZATION_EVIDENCE_VERSION), +}).strict().superRefine((value, context) => { + const expected = ["spawnfile", "moltnet", "daimon", "mneme"] as const; + const paths = value.files.map((item) => item.path); + const missing = value.missing_optional_files; + if (value.authorities.some((item, index) => item.authority !== expected[index]) + || new Set(paths).size !== paths.length + || paths.some((item, index) => index > 0 && paths[index - 1]! >= item) + || new Set(missing).size !== missing.length + || missing.some((item, index) => index > 0 && missing[index - 1]! >= item)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "composed organization evidence inventory is invalid", + }); + } +}); + +export type ComposedOrganizationEvidenceReceipt = z.infer; + +export class ComposedOrganizationEvidenceError extends Error { + readonly recovery_instruction = ORGANIZATION_EVIDENCE_RECOVERY_INSTRUCTION; + readonly source_preserved = true; + + constructor() { + super("composed organization evidence is recoverable"); + this.name = "ComposedOrganizationEvidenceError"; + } +} + +const authorityFor = (value: string): "daimon" | "mneme" | "moltnet" => { + const match = value.match(/^raw\/(daimon|mneme|moltnet)\//u)?.[1]; + if (match !== "daimon" && match !== "mneme" && match !== "moltnet") { + throw new TypeError("Spawnfile export contains an unknown authority"); + } + return match; +}; + +const inventoryBody = (receipt: ComposedOrganizationEvidenceReceipt) => ({ + files: receipt.files, + missing_optional_files: receipt.missing_optional_files, +}); + +export const parseComposedOrganizationEvidenceReceipt = ( + raw: unknown, +): ComposedOrganizationEvidenceReceipt => { + const receipt = parseComposedDigestedContract(raw, evidenceSchema, + COMPOSED_ORGANIZATION_EVIDENCE_VERSION, "composed organization evidence receipt"); + if (receipt.inventory_digest !== digestComposedJson( + "simfile.composed-organization-evidence-inventory.v1", inventoryBody(receipt), + )) throw new TypeError("composed organization evidence inventory digest is invalid"); + for (const entry of receipt.authorities) { + if (entry.authority === "spawnfile") { + if (entry.digest !== receipt.export_index_digest + || entry.item_count !== 1 || entry.missing_optional_count !== 0) { + throw new TypeError("composed Spawnfile evidence authority is invalid"); + } + continue; + } + const files = receipt.files.filter((item) => item.authority === entry.authority); + const missing = receipt.missing_optional_files.filter( + (item) => authorityFor(item) === entry.authority, + ); + if ((entry.authority !== "mneme" && files.length + missing.length < 1) + || entry.item_count !== files.length + || entry.missing_optional_count !== missing.length + || entry.digest !== digestComposedJson( + `simfile.composed-organization-evidence-authority.${entry.authority}.v1`, + { files, missing_optional_files: missing }, + )) throw new TypeError("composed organization evidence authority is invalid"); + } + return receipt; +}; + +const createEvidenceReceipt = (input: Readonly<{ + deployment: string; + organization_phase_digest: string; + raw: unknown; + run_id: string; +}>): ComposedOrganizationEvidenceReceipt => { + assertSecretFreeComposedJson(input.raw); + const result = rawResult.parse(input.raw); + if (result.failed_files.length > 0 + || result.deployment !== input.deployment + || result.index.deployment !== input.deployment + || result.index.run_id !== input.run_id) { + throw new TypeError("Spawnfile export correlation is invalid"); + } + const rawPaths = result.index.files.map((item) => item.path); + if (new Set(rawPaths).size !== rawPaths.length + || rawPaths.some((item, index) => index > 0 && rawPaths[index - 1]! >= item)) { + throw new TypeError("Spawnfile export index ordering is invalid"); + } + const files = result.index.files.map((item) => ({ + authority: authorityFor(item.path), + bytes: item.bytes, + path: item.path, + sha256: `sha256:${item.sha256}` as const, + })); + const missing = [...result.missing_optional_files].sort(); + missing.forEach(authorityFor); + const exportIndexDigest = digestComposedJson("spawnfile.export-index.v1", result.index); + const authorities = (["spawnfile", "moltnet", "daimon", "mneme"] as const).map((name) => { + if (name === "spawnfile") return { + authority: name, digest: exportIndexDigest, item_count: 1, missing_optional_count: 0, + }; + const items = files.filter((item) => item.authority === name); + const absent = missing.filter((item) => authorityFor(item) === name); + return { + authority: name, + digest: digestComposedJson( + `simfile.composed-organization-evidence-authority.${name}.v1`, + { files: items, missing_optional_files: absent }, + ), + item_count: items.length, + missing_optional_count: absent.length, + }; + }) as ComposedOrganizationEvidenceReceipt["authorities"]; + const inventory = { files, missing_optional_files: missing }; + return parseComposedOrganizationEvidenceReceipt(sealComposedContract( + COMPOSED_ORGANIZATION_EVIDENCE_VERSION, + { + authorities, + deployment: input.deployment, + export_index_digest: exportIndexDigest, + ...inventory, + inventory_digest: digestComposedJson( + "simfile.composed-organization-evidence-inventory.v1", inventory, + ), + organization_phase_digest: input.organization_phase_digest, + run_id: input.run_id, + source_state: "preserved", + state: "exported", + version: COMPOSED_ORGANIZATION_EVIDENCE_VERSION, + }, + )); +}; + +export interface ComposedOrganizationFinalizationPort { + exportOrganizationEvidence(input: Readonly<{ + deployment_name: string; + lifecycle_invocation_id: string; + organization_phase_digest: string; + run_id: string; + signal: AbortSignal; + }>): Promise; +} + +export const composedOrganizationExportLifecycleInvocationId = ( + requestDigest: string, +): string => + `lci_${digestComposedJson("simfile.composed-organization-export-operation.v1", { + operation: "artifacts_export", request_digest: composedDigestSchema.parse(requestDigest), + }).slice(7, 39)}`; + +/** Exports public Spawnfile evidence only after the world evidence is durable. */ +export const finalizeComposedOrganization = async (input: Readonly<{ + context: ComposedPhaseContext; + deployment_name: string; + journal: unknown; + port: ComposedOrganizationFinalizationPort; + signal?: AbortSignal; +}>): Promise => { + let journal = parseComposedPhaseJournal(input.journal); + if (!composedPhaseReached(journal, "world_evidence_exported")) { + throw new TypeError("composed organization finalization requires world evidence"); + } + if (composedPhaseReached(journal, "organization_evidence_exported")) return journal; + const organizationPhaseDigest = journal.entries[ + composedRunPhaseIndex("organization_ready") + ]!.payload_digest; + let evidence: ComposedOrganizationEvidenceReceipt; + try { + evidence = createEvidenceReceipt({ + deployment: input.deployment_name, + organization_phase_digest: organizationPhaseDigest, + raw: await input.port.exportOrganizationEvidence({ + deployment_name: input.deployment_name, + lifecycle_invocation_id: composedOrganizationExportLifecycleInvocationId( + journal.request_digest, + ), + organization_phase_digest: organizationPhaseDigest, + run_id: journal.request.run_id, + signal: input.signal ?? new AbortController().signal, + }), + run_id: journal.request.run_id, + }); + } catch { + throw new ComposedOrganizationEvidenceError(); + } + journal = await commitComposedPhase(journal, "organization_evidence_exported", { + evidence, receipt_digest: evidence.receipt_digest, run_id: journal.request.run_id, + }, input.context); + return journal; +}; diff --git a/src/compose/finalize-world.test.ts b/src/compose/finalize-world.test.ts new file mode 100644 index 0000000..b178835 --- /dev/null +++ b/src/compose/finalize-world.test.ts @@ -0,0 +1,169 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + ComposedWorldEvidenceError, + WORLD_EVIDENCE_RECOVERY_INSTRUCTION, + createComposedWorldEvidenceReceipt, + createComposedWorldPauseReceipt, + finalizeComposedWorld, + type ComposedWorldEvidenceItem, + type ComposedWorldFinalizationPort, +} from "./finalize-world.js"; +import type { ComposedPhaseJournal } from "./journal.js"; +import { + lifecycleDigest, + lifecycleHandle, + lifecyclePhaseContext, + lifecycleRequest, + terminalLifecycleJournal, + tickOneLifecycleJournal, +} from "./lifecycle.test-helper.js"; + +const inventory = (): ComposedWorldEvidenceItem[] => [ + { authority: "actions", bytes: 100, path: "actions/accepted.jsonl", sha256: lifecycleDigest("a") }, + { authority: "checkpoints", bytes: 200, path: "checkpoints/final.json", sha256: lifecycleDigest("b") }, + { authority: "projections", bytes: 300, path: "projections/world.json", sha256: lifecycleDigest("c") }, +]; + +type Failure = "copy" | "missing" | "tamper" | undefined; + +const fakePort = (failure?: Failure) => { + const calls = { cleanup: 0, export: 0, pause: 0 }; + const source = { preserved: true }; + const port: ComposedWorldFinalizationPort = { + exportWorldEvidence: async ({ pause }) => { + calls.export += 1; + if (failure === "copy") throw new Error("private provider copy failure"); + const items = failure === "missing" ? inventory().slice(0, 2) : inventory(); + const receipt = createComposedWorldEvidenceReceipt({ + export_handle: lifecycleHandle("7"), + inventory: items, + pause_receipt_digest: pause.receipt_digest, + run_id: pause.run_id, + source_service_handle: pause.service_handle, + }); + return failure === "tamper" + ? { ...receipt, inventory: [ + { ...receipt.inventory[0]!, sha256: lifecycleDigest("f") }, + ...receipt.inventory.slice(1), + ] } + : receipt; + }, + pauseWorld: async ({ service, terminal }) => { + calls.pause += 1; + return createComposedWorldPauseReceipt({ + final_tick: terminal.terminal_tick, + run_id: terminal.run_id, + service_handle: service.service_handle, + terminal_receipt_digest: terminal.receipt_digest, + }); + }, + }; + return { calls, port, source }; +}; + +test("world finalization pauses, flushes, hashes, and exports before cleanup", async () => { + const fake = fakePort(); + const journal = await finalizeComposedWorld({ + context: lifecyclePhaseContext().context, + journal: terminalLifecycleJournal(), + port: fake.port, + }); + assert.equal(journal.current_phase, "world_evidence_exported"); + assert.deepEqual(fake.calls, { cleanup: 0, export: 1, pause: 1 }); + assert.equal(fake.source.preserved, true); +}); + +test("world evidence admits only the root Spawnfile control namespace", () => { + const fields = { + export_handle: lifecycleHandle("7"), + pause_receipt_digest: lifecycleDigest("d"), + run_id: "run-one", + source_service_handle: lifecycleHandle("8"), + }; + const control = { + authority: "projections" as const, + bytes: 10, + path: ".spawnfile/world-service-activated.v1", + sha256: lifecycleDigest("e"), + }; + assert.equal(createComposedWorldEvidenceReceipt({ + ...fields, inventory: [...inventory(), control], + }).inventory.some(({ path }) => path === control.path), true); + for (const path of [".foreign/item.json", "projections/.hidden.json", + "nested/.spawnfile/item.json"]) { + assert.throws(() => createComposedWorldEvidenceReceipt({ + ...fields, inventory: [...inventory(), { ...control, path }], + })); + } +}); + +test("world finalization resumes both boundaries without repeating owner operations", async () => { + for (const failedPhase of ["world_paused", "world_evidence_exported"] as const) { + const request = lifecycleRequest({ run_id: `run-${failedPhase}` }); + const initial = terminalLifecycleJournal(request); + const fake = fakePort(); + const persisted: ComposedPhaseJournal[] = []; + await assert.rejects(finalizeComposedWorld({ + context: lifecyclePhaseContext({ + afterPhase: (phase) => { + if (phase === failedPhase) throw new Error(`fault after ${phase}`); + }, + persisted, + }).context, + journal: initial, + port: fake.port, + }), /fault after/u); + const journal = await finalizeComposedWorld({ + context: lifecyclePhaseContext().context, + journal: persisted.at(-1), + port: fake.port, + }); + assert.equal(journal.current_phase, "world_evidence_exported"); + assert.deepEqual(fake.calls, { cleanup: 0, export: 1, pause: 1 }); + } +}); + +test("copy, tamper, and missing-artifact failures preserve source with exact recovery", async () => { + for (const failure of ["copy", "tamper", "missing"] as const) { + const fake = fakePort(failure); + const persisted: ComposedPhaseJournal[] = []; + await assert.rejects(finalizeComposedWorld({ + context: lifecyclePhaseContext({ persisted }).context, + journal: terminalLifecycleJournal(), + port: fake.port, + }), (error: Error) => error instanceof ComposedWorldEvidenceError + && error.recovery_instruction === WORLD_EVIDENCE_RECOVERY_INSTRUCTION + && error.source_preserved); + assert.equal(persisted.at(-1)?.current_phase, "world_paused"); + assert.equal(fake.source.preserved, true); + assert.deepEqual(fake.calls, { cleanup: 0, export: 1, pause: 1 }); + } +}); + +test("world finalization rejects early and cross-run exports without cleanup", async () => { + const fake = fakePort(); + await assert.rejects(finalizeComposedWorld({ + context: lifecyclePhaseContext().context, + journal: tickOneLifecycleJournal(), + port: fake.port, + }), /requires terminal/u); + const crossRun: ComposedWorldFinalizationPort = { + ...fake.port, + exportWorldEvidence: async ({ pause }) => createComposedWorldEvidenceReceipt({ + export_handle: lifecycleHandle("7"), + inventory: inventory(), + pause_receipt_digest: pause.receipt_digest, + run_id: "run-foreign", + source_service_handle: pause.service_handle, + }), + }; + await assert.rejects(finalizeComposedWorld({ + context: lifecyclePhaseContext().context, + journal: terminalLifecycleJournal(), + port: crossRun, + }), (error: Error) => error instanceof ComposedWorldEvidenceError + && error.source_preserved); + assert.equal(fake.calls.cleanup, 0); +}); diff --git a/src/compose/finalize-world.ts b/src/compose/finalize-world.ts new file mode 100644 index 0000000..9662cdb --- /dev/null +++ b/src/compose/finalize-world.ts @@ -0,0 +1,209 @@ +import { z } from "zod"; +import { targetResourceReceiptSchema } from "../spawnfile/targetReceipts.js"; + +import { + composedDigestSchema, + composedHandleSchema, + composedRunIdSchema, + parseComposedDigestedContract, + sealComposedContract, +} from "./contracts.js"; +import { digestComposedJson } from "./json.js"; +import { parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; +import { + commitComposedPhase, + composedPhasePayload, + composedPhaseReached, + type ComposedPhaseContext, +} from "./phase.js"; +import { parseComposedWorldServiceReceipt } from "./startup-world.js"; +import { parseComposedWorldTerminalReceipt } from "./supervision.js"; + +export const COMPOSED_WORLD_PAUSE_VERSION = "simfile.composed-world-pause.v1" as const; +export const COMPOSED_WORLD_EVIDENCE_VERSION = "simfile.composed-world-evidence.v1" as const; +export const WORLD_EVIDENCE_RECOVERY_INSTRUCTION = + "resume the persisted composed journal at world_evidence_exported" as const; + +const evidencePath = z.string().max(512).regex( + /^(?:\.spawnfile\/)?(?:[a-z0-9][a-z0-9._-]*\/)*[a-z0-9][a-z0-9._-]*$/u, +); +const inventoryEntry = z.object({ + authority: z.enum(["actions", "checkpoints", "projections"]), + bytes: z.number().int().min(0).max(Number.MAX_SAFE_INTEGER), + path: evidencePath, + sha256: composedDigestSchema, +}).strict(); +const pauseSchema = z.object({ + final_tick: z.number().int().min(1).max(1_000_000_000), + receipt_digest: composedDigestSchema, + run_id: composedRunIdSchema, + service_handle: composedHandleSchema, + state: z.literal("paused"), + target_operation: targetResourceReceiptSchema.optional(), + terminal_receipt_digest: composedDigestSchema, + version: z.literal(COMPOSED_WORLD_PAUSE_VERSION), +}).strict(); +const evidenceSchema = z.object({ + export_handle: composedHandleSchema, + flushed_authorities: z.tuple([ + z.literal("actions"), z.literal("checkpoints"), z.literal("projections"), + ]), + inventory: z.array(inventoryEntry).min(3).max(100_000), + inventory_digest: composedDigestSchema, + item_count: z.number().int().min(3).max(100_000), + pause_receipt_digest: composedDigestSchema, + receipt_digest: composedDigestSchema, + run_id: composedRunIdSchema, + source_service_handle: composedHandleSchema, + source_state: z.literal("preserved"), + state: z.literal("exported"), + target_operation: targetResourceReceiptSchema.optional(), + version: z.literal(COMPOSED_WORLD_EVIDENCE_VERSION), +}).strict().superRefine((value, context) => { + const paths = value.inventory.map((item) => item.path); + const authorities = new Set(value.inventory.map((item) => item.authority)); + if (value.item_count !== value.inventory.length + || new Set(paths).size !== paths.length + || paths.some((item, index) => index > 0 && paths[index - 1]! >= item) + || (["actions", "checkpoints", "projections"] as const) + .some((item) => !authorities.has(item))) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "world evidence inventory is invalid" }); + } +}); + +export type ComposedWorldPauseReceipt = z.infer; +export type ComposedWorldEvidenceReceipt = z.infer; +export type ComposedWorldEvidenceItem = z.infer; + +export class ComposedWorldEvidenceError extends Error { + readonly recovery_instruction: string; + readonly source_preserved = true; + + constructor(nextPhase: "world_evidence_exported" | "world_paused") { + super("composed world evidence is recoverable"); + this.name = "ComposedWorldEvidenceError"; + this.recovery_instruction = nextPhase === "world_evidence_exported" + ? WORLD_EVIDENCE_RECOVERY_INSTRUCTION + : "resume the persisted composed journal at world_paused"; + } +} + +export const parseComposedWorldPauseReceipt = (raw: unknown): ComposedWorldPauseReceipt => + parseComposedDigestedContract(raw, pauseSchema, + COMPOSED_WORLD_PAUSE_VERSION, "composed world pause receipt"); +export const createComposedWorldPauseReceipt = ( + fields: Omit, +): ComposedWorldPauseReceipt => parseComposedWorldPauseReceipt(sealComposedContract( + COMPOSED_WORLD_PAUSE_VERSION, + { ...fields, state: "paused", version: COMPOSED_WORLD_PAUSE_VERSION }, +)); + +export const parseComposedWorldEvidenceReceipt = (raw: unknown): ComposedWorldEvidenceReceipt => { + const receipt = parseComposedDigestedContract(raw, evidenceSchema, + COMPOSED_WORLD_EVIDENCE_VERSION, "composed world evidence receipt"); + if (receipt.inventory_digest !== digestComposedJson( + "simfile.composed-world-evidence-inventory.v1", receipt.inventory, + )) throw new TypeError("composed world evidence inventory digest is invalid"); + return receipt; +}; +export const createComposedWorldEvidenceReceipt = (fields: Readonly<{ + export_handle: string; + inventory: readonly ComposedWorldEvidenceItem[]; + pause_receipt_digest: string; + run_id: string; + source_service_handle: string; + target_operation?: z.infer; +}>): ComposedWorldEvidenceReceipt => { + const inventory = [...fields.inventory].sort((left, right) => left.path.localeCompare(right.path)); + return parseComposedWorldEvidenceReceipt(sealComposedContract(COMPOSED_WORLD_EVIDENCE_VERSION, { + ...fields, + flushed_authorities: ["actions", "checkpoints", "projections"], + inventory, + inventory_digest: digestComposedJson("simfile.composed-world-evidence-inventory.v1", inventory), + item_count: inventory.length, + source_state: "preserved", + state: "exported", + version: COMPOSED_WORLD_EVIDENCE_VERSION, + })); +}; + +export interface ComposedWorldFinalizationPort { + exportWorldEvidence(input: Readonly<{ + idempotency_key: string; + pause: ComposedWorldPauseReceipt; + signal: AbortSignal; + }>): Promise; + pauseWorld(input: Readonly<{ + idempotency_key: string; + service: ReturnType; + signal: AbortSignal; + terminal: ReturnType; + }>): Promise; +} + +const operationKey = (journal: ComposedPhaseJournal, operation: string): string => + `idem_${digestComposedJson("simfile.composed-world-finalization-operation.v1", { + operation, request_digest: journal.request_digest, + }).slice(7, 39)}`; + +/** Pauses and exports exact world evidence; no destructive operation exists here. */ +export const finalizeComposedWorld = async (input: Readonly<{ + context: ComposedPhaseContext; + journal: unknown; + port: ComposedWorldFinalizationPort; + signal?: AbortSignal; +}>): Promise => { + let journal = parseComposedPhaseJournal(input.journal); + if (!composedPhaseReached(journal, "terminal")) { + throw new TypeError("composed world finalization requires terminal state"); + } + const service = parseComposedWorldServiceReceipt( + composedPhasePayload(journal, "world_started_paused").receipt, + ); + const terminal = parseComposedWorldTerminalReceipt( + composedPhasePayload(journal, "terminal").receipt, + ); + if (!composedPhaseReached(journal, "world_paused")) { + let pause: ComposedWorldPauseReceipt; + try { + pause = parseComposedWorldPauseReceipt(await input.port.pauseWorld({ + idempotency_key: operationKey(journal, "pause_world"), service, terminal, + signal: input.signal ?? new AbortController().signal, + })); + } catch { + throw new ComposedWorldEvidenceError("world_paused"); + } + if (pause.run_id !== journal.request.run_id + || pause.service_handle !== service.service_handle + || pause.terminal_receipt_digest !== terminal.receipt_digest + || pause.final_tick !== terminal.terminal_tick) { + throw new ComposedWorldEvidenceError("world_paused"); + } + journal = await commitComposedPhase(journal, "world_paused", { + receipt: pause, receipt_digest: pause.receipt_digest, run_id: journal.request.run_id, + }, input.context); + } + const pause = parseComposedWorldPauseReceipt( + composedPhasePayload(journal, "world_paused").receipt, + ); + if (!composedPhaseReached(journal, "world_evidence_exported")) { + let evidence: ComposedWorldEvidenceReceipt; + try { + evidence = parseComposedWorldEvidenceReceipt(await input.port.exportWorldEvidence({ + idempotency_key: operationKey(journal, "export_world_evidence"), pause, + signal: input.signal ?? new AbortController().signal, + })); + } catch { + throw new ComposedWorldEvidenceError("world_evidence_exported"); + } + if (evidence.run_id !== journal.request.run_id + || evidence.pause_receipt_digest !== pause.receipt_digest + || evidence.source_service_handle !== service.service_handle) { + throw new ComposedWorldEvidenceError("world_evidence_exported"); + } + journal = await commitComposedPhase(journal, "world_evidence_exported", { + evidence, receipt_digest: evidence.receipt_digest, run_id: journal.request.run_id, + }, input.context); + } + return journal; +}; diff --git a/src/compose/index.ts b/src/compose/index.ts new file mode 100644 index 0000000..aa6e362 --- /dev/null +++ b/src/compose/index.ts @@ -0,0 +1,26 @@ +export * from "./activation.js"; +export * from "./cleanup.js"; +export * from "./commandReceipt.js"; +export * from "./contracts.js"; +export * from "./execution.js"; +export * from "./finalize-world.js"; +export * from "./finalize-organization.js"; +export * from "./journal.js"; +export * from "./journalSession.js"; +export * from "./liveViewerProjection.js"; +export * from "./liveEvidence.js"; +export * from "./phase.js"; +export * from "./preflight.js"; +export * from "./projectBinding.js"; +export * from "./receipt.js"; +export * from "./recovery.js"; +export * from "./replay.js"; +export * from "./request.js"; +export * from "./run.js"; +export * from "./runRecord.js"; +export * from "./startup-world.js"; +export * from "./startup-organization.js"; +export * from "./supervision.js"; +export * from "./types.js"; +export * from "./viewer.js"; +export * from "./viewerBinding.js"; diff --git a/src/compose/journal.ts b/src/compose/journal.ts new file mode 100644 index 0000000..38d1394 --- /dev/null +++ b/src/compose/journal.ts @@ -0,0 +1,297 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { chmod, mkdir, open, readFile, rename, unlink } from "node:fs/promises"; +import path from "node:path"; + +import { z } from "zod"; + +import { + assertSecretFreeComposedJson, + canonicalComposedJson, + digestComposedJson, +} from "./json.js"; +import { + composedRunRequestSchema, + createComposedRunRequestDigest, + parseComposedRunRequest, + type ComposedRunRequest, +} from "./request.js"; +import { + COMPOSED_EXECUTION_VERSION, + composedExecutionSchema, + parseComposedExecution, + type ComposedExecution, +} from "./execution.js"; +import { + COMPOSED_RUN_PHASES, + composedRunPhaseIndex, + nextComposedRunPhase, + type ComposedRunPhase, +} from "./types.js"; + +export const COMPOSED_PHASE_JOURNAL_VERSION = "simfile.composed-phase-journal.v1" as const; +export const COMPOSED_JOURNAL_AUTHORITY_VERSION = "simfile.composed-journal-authority.v1" as const; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const timestamp = z.string().datetime({ offset: true }); +const payload = z.record(z.string(), z.unknown()); +const entry = z.object({ + payload, + payload_digest: digest, + phase: z.enum(COMPOSED_RUN_PHASES), + recorded_at: timestamp, + sequence: z.number().int().min(0).max(COMPOSED_RUN_PHASES.length - 1), +}).strict(); + +export const composedPhaseJournalSchema = z.object({ + authority_digest: digest, + current_phase: z.enum(COMPOSED_RUN_PHASES), + entries: z.array(entry).min(1).max(COMPOSED_RUN_PHASES.length), + execution: composedExecutionSchema.optional(), + genesis_nonce: z.string().regex(/^[a-f0-9]{64}$/u), + interruption: z.object({ + next_phase: z.enum(COMPOSED_RUN_PHASES), + recovery_command: z.string().min(1).max(8_192), + signal: z.enum(["SIGINT", "SIGTERM", "restart", "failure"]), + }).strict().nullable(), + journal_digest: digest, + request: composedRunRequestSchema, + request_digest: digest, + state: z.enum(["active", "recoverable", "complete"]), + version: z.literal(COMPOSED_PHASE_JOURNAL_VERSION), +}).strict(); + +export type ComposedPhaseJournal = z.infer; + +const payloadDigest = (phase: ComposedRunPhase, value: unknown): `sha256:${string}` => + digestComposedJson(`simfile.composed-phase.${phase}.v1`, value); + +const parseTimestamp = (value: string): number => { + const parsed = Date.parse(value); + if (!Number.isFinite(parsed)) throw new TypeError("composed journal timestamp is invalid"); + return parsed; +}; + +const authorityDigest = (input: Readonly<{ + execution?: ComposedExecution; + genesis_nonce: string; + recorded_at: string; + request_digest: string; +}>): `sha256:${string}` => digestComposedJson(COMPOSED_JOURNAL_AUTHORITY_VERSION, { + execution_digest: input.execution === undefined ? null + : digestComposedJson(COMPOSED_EXECUTION_VERSION, input.execution), + genesis_nonce: input.genesis_nonce, + recorded_at: input.recorded_at, + request_digest: input.request_digest, +}); + +export const parseComposedPhaseJournal = (raw: unknown): ComposedPhaseJournal => { + assertSecretFreeComposedJson(raw); + const journal = composedPhaseJournalSchema.parse(raw); + const execution = journal.execution === undefined + ? undefined + : parseComposedExecution(journal.execution); + const expectedAuthority = authorityDigest({ + ...(execution === undefined ? {} : { execution }), + genesis_nonce: journal.genesis_nonce, + recorded_at: journal.entries[0]!.recorded_at, + request_digest: journal.request_digest, + }); + if (journal.request_digest !== createComposedRunRequestDigest(journal.request) + || journal.authority_digest !== expectedAuthority + || journal.entries.length !== composedRunPhaseIndex(journal.current_phase) + 1 + || (execution !== undefined + && execution.provider.target_config_producer.args[0] !== journal.request.target.selector) + || (execution !== undefined + && execution.configuration.readiness_expectation.run_id !== journal.request.run_id) + || (execution !== undefined + && execution.configuration.readiness_expectation.bundle_digest + !== journal.request.world.bundle_digest) + || (execution !== undefined + && execution.configuration.organization_expectation.world_binding_digest + !== journal.request.organization.world_bindings_digest)) { + throw new TypeError("composed journal correlation is invalid"); + } + let previousTime = -Infinity; + for (const [index, phaseEntry] of journal.entries.entries()) { + assertSecretFreeComposedJson(phaseEntry.payload); + if (phaseEntry.phase !== COMPOSED_RUN_PHASES[index] + || phaseEntry.sequence !== index + || phaseEntry.payload.run_id !== journal.request.run_id + || phaseEntry.payload_digest !== payloadDigest(phaseEntry.phase, phaseEntry.payload)) { + throw new TypeError("composed journal transition is invalid"); + } + const currentTime = parseTimestamp(phaseEntry.recorded_at); + if (currentTime < previousTime) throw new TypeError("composed journal time regressed"); + previousTime = currentTime; + } + if ((journal.state === "complete") !== (journal.current_phase === "completed") + || (journal.state === "recoverable") !== (journal.interruption !== null)) { + throw new TypeError("composed journal state is contradictory"); + } + if (journal.interruption !== null + && journal.interruption.next_phase !== nextComposedRunPhase(journal.current_phase)) { + throw new TypeError("composed journal recovery phase is invalid"); + } + const { journal_digest: _journalDigest, ...body } = journal; + if (journal.journal_digest !== digestComposedJson(COMPOSED_PHASE_JOURNAL_VERSION, body)) { + throw new TypeError("composed journal digest is invalid"); + } + return Object.freeze(journal); +}; + +const seal = ( + body: Omit, +): ComposedPhaseJournal => parseComposedPhaseJournal({ + ...body, + journal_digest: digestComposedJson(COMPOSED_PHASE_JOURNAL_VERSION, body), +}); + +const phaseEntry = ( + phase: ComposedRunPhase, + value: Record, + recordedAt: string, +) => ({ + payload: value, + payload_digest: payloadDigest(phase, value), + phase, + recorded_at: timestamp.parse(recordedAt), + sequence: composedRunPhaseIndex(phase), +}); + +export const createComposedPhaseJournal = ( + rawRequest: unknown, + recordedAt: string, + rawExecution?: unknown, +): ComposedPhaseJournal => { + const request = parseComposedRunRequest(rawRequest); + const execution = rawExecution === undefined ? undefined : parseComposedExecution(rawExecution); + const initial = phaseEntry("requested", { + request_digest: createComposedRunRequestDigest(request), + run_id: request.run_id, + }, recordedAt); + const genesisNonce = randomBytes(32).toString("hex"); + const authority = authorityDigest({ + ...(execution === undefined ? {} : { execution }), + genesis_nonce: genesisNonce, + recorded_at: initial.recorded_at, + request_digest: createComposedRunRequestDigest(request), + }); + return seal({ + authority_digest: authority, + current_phase: "requested", + entries: [initial], + ...(execution === undefined ? {} : { execution }), + genesis_nonce: genesisNonce, + interruption: null, + request, + request_digest: createComposedRunRequestDigest(request), + state: "active", + version: COMPOSED_PHASE_JOURNAL_VERSION, + }); +}; + +export const appendComposedPhase = ( + rawJournal: unknown, + phase: ComposedRunPhase, + rawPayload: Record, + recordedAt: string, +): ComposedPhaseJournal => { + const journal = parseComposedPhaseJournal(rawJournal); + assertSecretFreeComposedJson(rawPayload); + if (rawPayload.run_id !== journal.request.run_id) { + throw new TypeError("composed phase run correlation is invalid"); + } + const requestedIndex = composedRunPhaseIndex(phase); + const currentIndex = composedRunPhaseIndex(journal.current_phase); + if (requestedIndex <= currentIndex) { + const existing = journal.entries[requestedIndex]; + if (!existing || existing.payload_digest !== payloadDigest(phase, rawPayload)) { + throw new TypeError("composed phase replay is contradictory"); + } + return journal; + } + if (requestedIndex !== currentIndex + 1 || journal.state === "complete") { + throw new TypeError("composed phase transition is not monotonic"); + } + const entries = [...journal.entries, phaseEntry(phase, rawPayload, recordedAt)]; + return seal({ + authority_digest: journal.authority_digest, + current_phase: phase, + entries, + ...(journal.execution === undefined ? {} : { execution: journal.execution }), + genesis_nonce: journal.genesis_nonce, + interruption: null, + request: journal.request, + request_digest: journal.request_digest, + state: phase === "completed" ? "complete" : "active", + version: COMPOSED_PHASE_JOURNAL_VERSION, + }); +}; + +export const markComposedJournalRecoverable = ( + rawJournal: unknown, + input: Readonly<{ + recovery_command: string; + signal: "SIGINT" | "SIGTERM" | "restart" | "failure"; + }>, +): ComposedPhaseJournal => { + const journal = parseComposedPhaseJournal(rawJournal); + const nextPhase = nextComposedRunPhase(journal.current_phase); + if (nextPhase === null || journal.state === "complete") { + throw new TypeError("completed journal cannot require recovery"); + } + return seal({ + authority_digest: journal.authority_digest, + current_phase: journal.current_phase, + entries: journal.entries, + ...(journal.execution === undefined ? {} : { execution: journal.execution }), + genesis_nonce: journal.genesis_nonce, + interruption: { + next_phase: nextPhase, + recovery_command: input.recovery_command, + signal: input.signal, + }, + request: journal.request, + request_digest: journal.request_digest, + state: "recoverable", + version: COMPOSED_PHASE_JOURNAL_VERSION, + }); +}; + +const exactJournalPath = (value: string): string => { + if (!path.isAbsolute(value) || path.normalize(value) !== value + || value === path.parse(value).root || Buffer.byteLength(value, "utf8") > 4_096) { + throw new TypeError("composed journal path is invalid"); + } + return value; +}; + +export const writeComposedPhaseJournal = async ( + journalPath: string, + rawJournal: unknown, +): Promise => { + const target = exactJournalPath(journalPath); + const journal = parseComposedPhaseJournal(rawJournal); + const directory = path.dirname(target); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); + const temporary = `${target}.${process.pid}.${randomUUID()}.pending`; + const handle = await open(temporary, "wx", 0o600); + try { + await handle.writeFile(`${canonicalComposedJson(journal)}\n`, "utf8"); + await handle.sync(); + } finally { + await handle.close(); + } + try { + await rename(temporary, target); + } finally { + await unlink(temporary).catch(() => undefined); + } +}; + +export const readComposedPhaseJournal = async ( + journalPath: string, +): Promise => parseComposedPhaseJournal( + JSON.parse(await readFile(exactJournalPath(journalPath), "utf8")) as unknown, +); diff --git a/src/compose/journalSession.test.ts b/src/compose/journalSession.test.ts new file mode 100644 index 0000000..6458159 --- /dev/null +++ b/src/compose/journalSession.test.ts @@ -0,0 +1,82 @@ +import assert from "node:assert/strict"; +import { mkdtemp, readFile, rm, symlink } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createComposedPhaseJournal, markComposedJournalRecoverable, writeComposedPhaseJournal } from "./journal.js"; +import { createComposedJournalSession, openComposedJournalSession } from "./journalSession.js"; +import { lifecycleDigest, lifecycleRequest } from "./lifecycle.test-helper.js"; +import { composedRecoveryCommand } from "./receipt.js"; + +const expected = (journal: ReturnType) => ({ + authority_digest: journal.authority_digest, + run_id: journal.request.run_id, +}); + +test("journal sessions pin identity and compare the exact prior generation", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-journal-session-")); + try { + const target = path.join(root, "journal.json"); + const initial = createComposedPhaseJournal(lifecycleRequest({ run_id: "run-session" }), + "2026-08-07T00:00:00.000Z"); + const session = await createComposedJournalSession(target, initial); + const next = markComposedJournalRecoverable(initial, { + recovery_command: composedRecoveryCommand( + target, initial.request.run_id, initial.authority_digest, + ), signal: "failure", + }); + await session.replace(initial, next); + assert.deepEqual(session.current(), next); + await session.assertCurrent(next); + await writeComposedPhaseJournal(target, next); + await assert.rejects(session.assertCurrent(), /identity changed/u); + + const link = path.join(root, "journal-link.json"); + await symlink(target, link); + await assert.rejects(openComposedJournalSession(link, expected(initial)), /unsafe/u); + } finally { await rm(root, { force: true, recursive: true }); } +}); + +test("journal replacement never overwrites a hostile intervening generation", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-journal-race-")); + try { + const target = path.join(root, "journal.json"); + const initial = createComposedPhaseJournal(lifecycleRequest({ run_id: "run-owned" }), + "2026-08-07T00:00:00.000Z"); + const foreign = createComposedPhaseJournal(lifecycleRequest({ run_id: "run-foreign" }), + "2026-08-07T00:00:00.000Z"); + const session = await createComposedJournalSession(target, initial, { + beforeReplaceCommit: () => writeComposedPhaseJournal(target, foreign), + }); + const next = markComposedJournalRecoverable(initial, { + recovery_command: composedRecoveryCommand( + target, initial.request.run_id, initial.authority_digest, + ), signal: "failure", + }); + await assert.rejects(session.replace(initial, next), /identity changed/u); + assert.match(await readFile(target, "utf8"), /run-foreign/u); + } finally { await rm(root, { force: true, recursive: true }); } +}); + +test("opening a journal requires its exact run and immutable genesis authority", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-journal-authority-")); + try { + const target = path.join(root, "journal.json"); + const owned = createComposedPhaseJournal(lifecycleRequest({ run_id: "run-owned" }), + "2026-08-07T00:00:00.000Z"); + await writeComposedPhaseJournal(target, owned); + assert.deepEqual((await openComposedJournalSession(target, expected(owned))).current(), owned); + for (const foreign of [ + createComposedPhaseJournal(lifecycleRequest({ run_id: "run-foreign" }), + "2026-08-07T00:00:00.000Z"), + createComposedPhaseJournal(lifecycleRequest({ run_id: "run-owned" }), + "2026-08-07T00:00:00.000Z"), + createComposedPhaseJournal(lifecycleRequest({ run_id: "run-owned", + descriptor_digest: lifecycleDigest("9") }), "2026-08-07T00:00:00.000Z"), + ]) { + await writeComposedPhaseJournal(target, foreign); + await assert.rejects(openComposedJournalSession(target, expected(owned)), /authority changed/u); + } + } finally { await rm(root, { force: true, recursive: true }); } +}); diff --git a/src/compose/journalSession.ts b/src/compose/journalSession.ts new file mode 100644 index 0000000..d4ebbfd --- /dev/null +++ b/src/compose/journalSession.ts @@ -0,0 +1,163 @@ +import { randomUUID } from "node:crypto"; +import { constants } from "node:fs"; +import { chmod, link, lstat, mkdir, open, rename, unlink } from "node:fs/promises"; +import path from "node:path"; + +import { canonicalComposedJson } from "./json.js"; +import { parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; + +interface FileIdentity { readonly dev: number; readonly ino: number } +interface Snapshot { readonly identity: FileIdentity; readonly journal: ComposedPhaseJournal } +export interface ComposedJournalSession { + readonly path: string; + assertCurrent(expected?: ComposedPhaseJournal): Promise; + current(): ComposedPhaseJournal; + replace(expected: ComposedPhaseJournal, next: ComposedPhaseJournal): Promise; +} +export interface ComposedJournalSessionOptions { + readonly beforeReplaceCommit?: () => Promise | void; +} +export interface ComposedJournalAuthorityExpectation { + readonly authority_digest: string; + readonly run_id: string; +} + +const exactPath = (value: string): string => { + if (!path.isAbsolute(value) || path.normalize(value) !== value + || value === path.parse(value).root || Buffer.byteLength(value, "utf8") > 4_096) { + throw new TypeError("composed journal path is invalid"); + } + return value; +}; +const expectedAuthority = (raw: unknown): ComposedJournalAuthorityExpectation => { + if (raw === null || typeof raw !== "object" || Array.isArray(raw) + || Object.getPrototypeOf(raw) !== Object.prototype) { + throw new TypeError("composed journal authority expectation is invalid"); + } + const value = raw as Record; + if (Object.keys(value).sort().join("\0") !== "authority_digest\0run_id" + || typeof value.authority_digest !== "string" + || !/^sha256:[a-f0-9]{64}$/u.test(value.authority_digest) + || typeof value.run_id !== "string" + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(value.run_id)) { + throw new TypeError("composed journal authority expectation is invalid"); + } + return { authority_digest: value.authority_digest, run_id: value.run_id }; +}; +const identityMatches = (left: FileIdentity, right: FileIdentity): boolean => + left.dev === right.dev && left.ino === right.ino; +const owned = (uid: number): boolean => process.getuid === undefined || uid === process.getuid(); +const exactDirectory = async (directory: string): Promise => { + const info = await lstat(directory); + if (!info.isDirectory() || info.isSymbolicLink() || !owned(info.uid) + || (process.platform !== "win32" && (info.mode & 0o777) !== 0o700)) { + throw new TypeError("composed journal directory is unsafe"); + } +}; +const snapshot = async (target: string, expected?: FileIdentity): Promise => { + await exactDirectory(path.dirname(target)); + const before = await lstat(target); + if (!before.isFile() || before.isSymbolicLink() || before.nlink !== 1 || !owned(before.uid) + || before.size < 2 || before.size > 16_777_216 + || (process.platform !== "win32" && (before.mode & 0o777) !== 0o600)) { + throw new TypeError("composed journal file is unsafe"); + } + const identity = { dev: before.dev, ino: before.ino }; + if (expected !== undefined && !identityMatches(identity, expected)) { + throw new TypeError("composed journal file identity changed"); + } + const file = await open(target, constants.O_RDONLY | constants.O_NOFOLLOW); + try { + const opened = await file.stat(); + if (!identityMatches(identity, opened)) throw new TypeError("composed journal file changed"); + const journal = parseComposedPhaseJournal(JSON.parse(await file.readFile("utf8")) as unknown); + const after = await lstat(target); + if (!identityMatches(identity, after)) throw new TypeError("composed journal file changed"); + return { identity, journal }; + } finally { + await file.close(); + } +}; +const bytes = (journal: ComposedPhaseJournal): string => `${canonicalComposedJson(journal)}\n`; +const sameJournal = (left: ComposedPhaseJournal, right: ComposedPhaseJournal): boolean => + left.journal_digest === right.journal_digest && left.request_digest === right.request_digest + && left.request.run_id === right.request.run_id && bytes(left) === bytes(right); +const syncDirectory = async (directory: string): Promise => { + const handle = await open(directory, constants.O_RDONLY); + try { await handle.sync(); } finally { await handle.close(); } +}; + +class Session implements ComposedJournalSession { + readonly path: string; + readonly #options: ComposedJournalSessionOptions; + #snapshot: Snapshot; + constructor(target: string, initial: Snapshot, options: ComposedJournalSessionOptions) { + this.path = target; this.#snapshot = initial; this.#options = options; + } + current(): ComposedPhaseJournal { return this.#snapshot.journal; } + async assertCurrent(expected = this.#snapshot.journal): Promise { + if (!sameJournal(expected, this.#snapshot.journal)) { + throw new TypeError("composed journal session expectation changed"); + } + const current = await snapshot(this.path, this.#snapshot.identity); + if (!sameJournal(current.journal, expected)) throw new TypeError("composed journal bytes changed"); + } + async replace(expected: ComposedPhaseJournal, rawNext: ComposedPhaseJournal): Promise { + const next = parseComposedPhaseJournal(rawNext); + if (expected.request_digest !== next.request_digest + || expected.request.run_id !== next.request.run_id) { + throw new TypeError("composed journal replacement correlation changed"); + } + await this.assertCurrent(expected); + const temporary = `${this.path}.${process.pid}.${randomUUID()}.pending`; + const file = await open(temporary, "wx", 0o600); + try { + await file.writeFile(bytes(next), "utf8"); await file.sync(); + } finally { await file.close(); } + try { + await this.#options.beforeReplaceCommit?.(); + await this.assertCurrent(expected); + await rename(temporary, this.path); + await syncDirectory(path.dirname(this.path)); + const current = await snapshot(this.path); + if (!sameJournal(current.journal, next)) throw new TypeError("composed journal commit changed"); + this.#snapshot = current; + } finally { await unlink(temporary).catch(() => undefined); } + } +} + +export const openComposedJournalSession = async ( + rawPath: string, + rawExpected: ComposedJournalAuthorityExpectation, + options: ComposedJournalSessionOptions = {}, +): Promise => { + const target = exactPath(rawPath); + const expected = expectedAuthority(rawExpected); + const initial = await snapshot(target); + if (initial.journal.authority_digest !== expected.authority_digest + || initial.journal.request.run_id !== expected.run_id) { + throw new TypeError("composed journal authority changed"); + } + return new Session(target, initial, options); +}; + +export const createComposedJournalSession = async ( + rawPath: string, + rawJournal: ComposedPhaseJournal, + options: ComposedJournalSessionOptions = {}, +): Promise => { + const target = exactPath(rawPath); + const journal = parseComposedPhaseJournal(rawJournal); + const directory = path.dirname(target); + await mkdir(directory, { recursive: true, mode: 0o700 }); + await chmod(directory, 0o700); await exactDirectory(directory); + const temporary = `${target}.${process.pid}.${randomUUID()}.pending`; + const file = await open(temporary, "wx", 0o600); + try { await file.writeFile(bytes(journal), "utf8"); await file.sync(); } finally { await file.close(); } + try { + await link(temporary, target); + await unlink(temporary); + await syncDirectory(directory); + return new Session(target, await snapshot(target), options); + } finally { await unlink(temporary).catch(() => undefined); } +}; diff --git a/src/compose/json.ts b/src/compose/json.ts new file mode 100644 index 0000000..65c50dc --- /dev/null +++ b/src/compose/json.ts @@ -0,0 +1,99 @@ +import { createHash } from "node:crypto"; +import { types } from "node:util"; + +const MAX_DEPTH = 32; +const MAX_NODES = 4_096; +const MAX_KEYS = 256; +const MAX_STRING_BYTES = 262_144; +const forbiddenKey = /^(?:authorization|bearer|credential|password|private_config|secret|target_config|token)$/iu; +const secretValue = /(?:\bBearer\s+\S+|\b(?:password|token)\s*=|-----BEGIN [A-Z ]+PRIVATE KEY-----|\bsk-[A-Za-z0-9_-]{16,})/u; + +export const assertOrdinaryComposedJson = (raw: unknown): void => { + const seen = new WeakSet(); + const pending: Array<{ depth: number; value: unknown }> = [{ depth: 0, value: raw }]; + let nodes = 0; + let stringBytes = 0; + while (pending.length > 0) { + const item = pending.pop()!; + const value = item.value; + if (typeof value === "string") { + stringBytes += Buffer.byteLength(value, "utf8"); + if (stringBytes > MAX_STRING_BYTES) throw new TypeError("invalid composed JSON graph"); + continue; + } + if (value === null || typeof value === "boolean") continue; + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new TypeError("invalid composed JSON graph"); + continue; + } + if (typeof value !== "object" || types.isProxy(value) || item.depth > MAX_DEPTH + || seen.has(value)) throw new TypeError("invalid composed JSON graph"); + seen.add(value); + nodes += 1; + if (nodes > MAX_NODES) throw new TypeError("invalid composed JSON graph"); + const prototype = Object.getPrototypeOf(value); + if (Array.isArray(value)) { + if (prototype !== Array.prototype || value.length > MAX_KEYS + || Reflect.ownKeys(value).length !== value.length + 1) { + throw new TypeError("invalid composed JSON graph"); + } + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !("value" in descriptor)) { + throw new TypeError("invalid composed JSON graph"); + } + pending.push({ depth: item.depth + 1, value: descriptor.value }); + } + continue; + } + if (prototype !== Object.prototype && prototype !== null) { + throw new TypeError("invalid composed JSON graph"); + } + const keys = Reflect.ownKeys(value); + if (keys.length > MAX_KEYS) throw new TypeError("invalid composed JSON graph"); + for (const key of keys) { + if (typeof key !== "string") throw new TypeError("invalid composed JSON graph"); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) { + throw new TypeError("invalid composed JSON graph"); + } + stringBytes += Buffer.byteLength(key, "utf8"); + pending.push({ depth: item.depth + 1, value: descriptor.value }); + } + } +}; + +export const assertSecretFreeComposedJson = (raw: unknown): void => { + assertOrdinaryComposedJson(raw); + const pending: unknown[] = [raw]; + while (pending.length > 0) { + const value = pending.pop(); + if (typeof value === "string") { + if (secretValue.test(value)) throw new TypeError("secret-shaped composed value"); + } else if (Array.isArray(value)) { + pending.push(...value); + } else if (value !== null && typeof value === "object") { + for (const [key, nested] of Object.entries(value)) { + if (forbiddenKey.test(key)) throw new TypeError("secret-shaped composed field"); + pending.push(nested); + } + } + } +}; + +export const canonicalComposedJson = (raw: unknown): string => { + assertOrdinaryComposedJson(raw); + if (Array.isArray(raw)) return `[${raw.map(canonicalComposedJson).join(",")}]`; + if (raw !== null && typeof raw === "object") { + const record = raw as Record; + return `{${Object.keys(record).sort().map((key) => + `${JSON.stringify(key)}:${canonicalComposedJson(record[key])}`).join(",")}}`; + } + const serialized = JSON.stringify(raw); + if (serialized === undefined) throw new TypeError("invalid composed JSON value"); + return serialized; +}; + +export const digestComposedJson = (domain: string, raw: unknown): `sha256:${string}` => + `sha256:${createHash("sha256").update(`${domain}\0`, "utf8") + .update(canonicalComposedJson(raw), "utf8").digest("hex")}`; diff --git a/src/compose/lifecycle.test-helper.ts b/src/compose/lifecycle.test-helper.ts new file mode 100644 index 0000000..a1cf460 --- /dev/null +++ b/src/compose/lifecycle.test-helper.ts @@ -0,0 +1,399 @@ +import { createHash } from "node:crypto"; +import { + createWorldSidecarReadiness, + type WorldSidecarReadiness, + type WorldSidecarReadinessExpectation, +} from "../world-artifact/readiness.js"; +import { appendComposedPhase, createComposedPhaseJournal } from "./journal.js"; +import { + parseComposedRunRequest, + WORLD_DECISION_CLAIM_CAPABILITY, + type ComposedRunRequest, +} from "./request.js"; +import type { ComposedPhaseJournal } from "./journal.js"; +import { + createComposedWorldResourceReceipt, + createComposedWorldServiceReceipt, +} from "./startup-world.js"; +import { canonicalComposedJson, digestComposedJson } from "./json.js"; +import { + deriveComposedOrganizationDeploymentHandle, + type ComposedOrganizationExpectation, +} from "./startup-organization.js"; +import { + createComposedRunningReceipt, + createComposedWorldTerminalReceipt, + parseComposedWorldTerminalReceipt, +} from "./supervision.js"; +import { + createComposedWorldEvidenceReceipt, + createComposedWorldPauseReceipt, +} from "./finalize-world.js"; +import { COMPOSED_RUN_PHASES } from "./types.js"; +export const lifecycleDigest = (value: string): `sha256:${string}` => `sha256:${value.repeat(64).slice(0, 64)}`; +export const lifecycleHandle = (value: string): `opaque_${string}` => `opaque_${value.repeat(16).slice(0, 16)}`; +export const lifecycleRequest = ( + overrides: Partial = {}, +): ComposedRunRequest => parseComposedRunRequest({ + descriptor_digest: lifecycleDigest("a"), + mode: "dry-run", + organization: { + artifact_digest: lifecycleDigest("b"), + source_digest: lifecycleDigest("c"), + world_bindings_digest: lifecycleDigest("d"), + }, + required_world_capabilities: [], + run_id: "run-lifecycle", + source_digest: lifecycleDigest("e"), + target: { auth_profile: "simfile-live", selector: "gpu-4090" }, + version: "simfile.composed-run-request.v1", + world: { + artifact_manifest_digest: lifecycleDigest("f"), + bundle_digest: lifecycleDigest("1"), + runtime_abi: "simfile.world-sidecar-runtime.v1", + }, + ...overrides, +}); +const lifecycleSelectedTarget = () => ({ + fingerprint: `sha256:${"1".repeat(32)}`, + handle: lifecycleHandle("6"), + version: "spawnfile.target-resource.selected-target.v1" as const, +}); +const lifecycleSelectedTargetDigest = (): `sha256:${string}` => + `sha256:${createHash("sha256").update(canonicalComposedJson(lifecycleSelectedTarget())) + .digest("hex")}`; +export const lifecyclePreparation = (request = lifecycleRequest()) => { + const selected = lifecycleSelectedTarget(); + const resource = (input: Readonly<{ + operation: "create_data_network" | "create_evidence_volume" | "prepare_secret_bindings" | "resolve_world_artifact"; + operationCharacter: string; + resultCharacter: string; + revision: number; + }>) => { + const body = { + cleanup_state: "not_requested" as const, + descriptor_digest: request.descriptor_digest, + export_state: "not_requested" as const, + labels: [], + operation: input.operation, + operation_handle: lifecycleHandle(input.operationCharacter), + request_digest: lifecycleDigest(input.operationCharacter), + result_handle: lifecycleHandle(input.resultCharacter), + resulting_revision: input.revision, + run_id: request.run_id, + selected_target: { fingerprint: selected.fingerprint, handle: selected.handle }, + version: "spawnfile.target-resource.receipt.v1" as const, + }; + return { + ...body, + receipt_digest: digestComposedJson("spawnfile.target-resource.receipt.v1", body), + }; + }; + const body = { + auth_profile: request.target.auth_profile, + descriptor_digest: request.descriptor_digest, + organization: { + artifact_digest: request.organization.artifact_digest, + world_bindings_digest: request.organization.world_bindings_digest, + }, + request_digest: lifecycleDigest("4"), + resources: { + data_network: resource({ + operation: "create_data_network", operationCharacter: "7", resultCharacter: "c", revision: 3, + }), + evidence_volume: resource({ + operation: "create_evidence_volume", operationCharacter: "8", resultCharacter: "d", revision: 4, + }), + secret_bindings: resource({ + operation: "prepare_secret_bindings", operationCharacter: "f", resultCharacter: "b", revision: 2, + }), + world_artifact: resource({ + operation: "resolve_world_artifact", operationCharacter: "e", resultCharacter: "a", revision: 1, + }), + }, + run_id: request.run_id, + selected_target: selected, + target_selector: request.target.selector, + version: "spawnfile.composed-preparation.receipt.v1" as const, + world: { + artifact_manifest_digest: request.world.artifact_manifest_digest, + bundle_digest: request.world.bundle_digest, + }, + }; + return Object.freeze({ + ...body, + receipt_digest: digestComposedJson("spawnfile.composed-preparation.receipt.v1", body), + }); +}; + +export const preparedLifecycleJournal = ( + request = lifecycleRequest(), +): ComposedPhaseJournal => appendComposedPhase( + createComposedPhaseJournal(request, "2026-01-01T00:00:00.000Z"), + "prepared", + { + preparation: lifecyclePreparation(request), + preparation_receipt_digest: lifecyclePreparation(request).receipt_digest, + run_id: request.run_id, + }, + "2026-01-01T00:00:01.000Z", +); + +export const lifecycleReadinessExpectation = ( + request = lifecycleRequest(), + advertiseClaim = true, +): WorldSidecarReadinessExpectation => Object.freeze({ + artifact_digest: lifecycleDigest("8"), + bundle_digest: request.world.bundle_digest, + capability_manifest_digests: [lifecycleDigest("7")], + ...(advertiseClaim ? { capabilities: [{ + identity: WORLD_DECISION_CLAIM_CAPABILITY, + manifest_digest: lifecycleDigest("7"), + }] } : {}), + mechanics_sha256: lifecycleDigest("6"), + normalized_checkpoint_sha256: lifecycleDigest("5"), + run_id: request.run_id, + world_instance_id: `${request.run_id}-world`, +}); + +export const lifecycleReadiness = ( + request = lifecycleRequest(), + advertiseClaim = true, +): WorldSidecarReadiness => createWorldSidecarReadiness({ + ...lifecycleReadinessExpectation(request, advertiseClaim), + clock: { next_tick: 0, state: "paused" }, + decisions: { count: 0, phase: "open" }, + runtime_abi: "simfile.world-sidecar-runtime.v1", + status: "ready", + version: "simfile.world-sidecar-readiness.v1", +}); + +export const worldReadyLifecycleJournal = ( + request = lifecycleRequest(), + advertiseClaim = true, +): ComposedPhaseJournal => { + const preparation = lifecyclePreparation(request); + const resource = createComposedWorldResourceReceipt({ + artifact_digest: request.world.artifact_manifest_digest, + bundle_digest: request.world.bundle_digest, + preparation_receipt_digest: preparation.receipt_digest, + resource_handle: lifecycleHandle("2"), + run_id: request.run_id, + }); + const service = createComposedWorldServiceReceipt({ + resource_handle: resource.resource_handle, + run_id: request.run_id, + service_handle: lifecycleHandle("3"), + }); + const readiness = lifecycleReadiness(request, advertiseClaim); + let journal = preparedLifecycleJournal(request); + journal = appendComposedPhase(journal, "world_created", { + receipt: resource, run_id: request.run_id, + }, "2026-01-01T00:00:02.000Z"); + journal = appendComposedPhase(journal, "world_started_paused", { + receipt: service, run_id: request.run_id, + }, "2026-01-01T00:00:03.000Z"); + return appendComposedPhase(journal, "world_ready", { + readiness, + readiness_digest: digestComposedJson("simfile.composed-world-readiness.v1", readiness), + run_id: request.run_id, + }, "2026-01-01T00:00:04.000Z"); +}; + +export const lifecycleOrganizationExpectation = (): ComposedOrganizationExpectation => ({ + deployment_name: "organization-unit", unit_id: "organization-unit-container", + member_engines: { "member:alpha": "engine-one", "member:beta": "engine-two" }, + moltnet_release: { + architecture: "amd64", + asset_sha256: lifecycleDigest("a"), + release_version: "v0.1.14-1-gaaaaaaa", + source_revision: "a".repeat(40), + }, + selected_target_receipt_digest: lifecycleSelectedTargetDigest(), + world_binding_digest: lifecycleDigest("d"), +}); + +export const lifecycleOrganizationUpReceipt = (runId: string, ready: boolean) => { + const handoff = { + binding_digest: lifecycleDigest("d"), + lifecycle_receipts: { + down: "spawnfile.down-receipt.v1" as const, + export: "spawnfile.export-index.v1" as const, + up: "spawnfile.up-receipt.v1" as const, + }, + network_attachment_handle: lifecycleHandle("4"), + run_id: runId, + selected_target_receipt_digest: lifecycleSelectedTargetDigest(), + }; + const attachmentBody = { + cleanup_state: "not_requested" as const, + descriptor_digest: lifecycleDigest("a"), + export_state: "not_requested" as const, + labels: [], + operation: "attach_organization" as const, + operation_handle: lifecycleHandle("9"), + request_digest: lifecycleDigest("9"), + result_handle: lifecycleHandle("6"), + resulting_revision: 7, + run_id: runId, + selected_target: { + fingerprint: lifecycleSelectedTarget().fingerprint, + handle: lifecycleSelectedTarget().handle, + }, + version: "spawnfile.target-resource.receipt.v1" as const, + }; + return { + compiled_schedule: [], + deployment: { container_ids: ["organization-container"], name: "organization-unit" }, + engines: [ + { agent: "member:alpha", engine: "engine-one" }, + { agent: "member:beta", engine: "engine-two" }, + ], + fingerprint: "sf1:0123456789ab", + moltnet_release: { + architecture: "amd64", + asset: "moltnet_linux_amd64.tar.gz", + asset_sha256: lifecycleDigest("a"), + capabilities: ["pi-bridge"], + release_version: "v0.1.14-1-gaaaaaaa", + source_revision: "a".repeat(40), + version: "spawnfile.moltnet-release-identity.v1", + }, + organization_handoff: { + ...handoff, + deployment_handle: deriveComposedOrganizationDeploymentHandle(handoff), + version: "spawnfile.organization-handoff.v1", + }, + organization_handoff_handle: lifecycleHandle("5"), + target_attachment: { ...attachmentBody, + receipt_digest: digestComposedJson( + "spawnfile.target-resource.receipt.v1", attachmentBody, + ) }, + ...(ready ? { + organization_ready: { + code: "organization_ready", + compile_fingerprint: "sf1:0123456789ab", + run_id: runId, + state: "ready", + unit_id: "organization-unit-container", + version: "spawnfile.organization-ready.v1", + world_binding_digest: lifecycleDigest("d"), + }, + } : {}), + readiness: { moltnet_base_url: "http://organization.internal:19971", state: "running" }, + run_id: runId, + version: "spawnfile.up-receipt.v1", + }; +}; + +export const organizationReadyLifecycleJournal = ( + request = lifecycleRequest(), + advertiseClaim = true, +): ComposedPhaseJournal => { + const started = lifecycleOrganizationUpReceipt(request.run_id, false); + const ready = lifecycleOrganizationUpReceipt(request.run_id, true); + let journal = worldReadyLifecycleJournal(request, advertiseClaim); + journal = appendComposedPhase(journal, "organization_started", { + run_id: request.run_id, + up_receipt: started, + up_receipt_digest: digestComposedJson("spawnfile.up-receipt.v1", started), + }, "2026-01-01T00:00:05.000Z"); + return appendComposedPhase(journal, "organization_ready", { + moltnet_release: ready.moltnet_release, + organization_handoff: ready.organization_handoff, + readiness: ready.organization_ready, + receipt_digest: digestComposedJson("spawnfile.up-receipt.v1", ready), + run_id: request.run_id, + }, "2026-01-01T00:00:06.000Z"); +}; + +export const tickOneLifecycleJournal = ( + request = lifecycleRequest(), +): ComposedPhaseJournal => { + let journal = organizationReadyLifecycleJournal(request); + journal = appendComposedPhase(journal, "topology_verified", { + receipt_digest: lifecycleDigest("7"), run_id: request.run_id, + }, "2026-01-01T00:00:07.000Z"); + journal = appendComposedPhase(journal, "activated", { + receipt_digest: lifecycleDigest("8"), run_id: request.run_id, + }, "2026-01-01T00:00:08.000Z"); + return appendComposedPhase(journal, "tick_1", { + receipt_digest: lifecycleDigest("9"), run_id: request.run_id, + }, "2026-01-01T00:00:09.000Z"); +}; + +export const terminalLifecycleJournal = ( + request = lifecycleRequest(), + terminalTick = 4, +): ComposedPhaseJournal => { + let journal = tickOneLifecycleJournal(request); + const running = createComposedRunningReceipt({ + activation_receipt_digest: lifecycleDigest("8"), + first_tick_receipt_digest: lifecycleDigest("9"), + run_id: request.run_id, + }); + journal = appendComposedPhase(journal, "running", { + receipt: running, receipt_digest: running.receipt_digest, run_id: request.run_id, + }, "2026-01-01T00:00:10.000Z"); + const terminal = createComposedWorldTerminalReceipt({ + outcome_digest: lifecycleDigest("0"), + reason: "completed", + run_id: request.run_id, + running_receipt_digest: running.receipt_digest, + terminal_tick: terminalTick, + }); + return appendComposedPhase(journal, "terminal", { + receipt: terminal, receipt_digest: terminal.receipt_digest, run_id: request.run_id, + }, "2026-01-01T00:00:11.000Z"); +}; + +export const worldEvidenceLifecycleJournal = ( + request = lifecycleRequest(), +): ComposedPhaseJournal => { + let journal = terminalLifecycleJournal(request); + const terminal = parseComposedWorldTerminalReceipt( + journal.entries[11]!.payload.receipt, + ); + const pause = createComposedWorldPauseReceipt({ + final_tick: terminal.terminal_tick, + run_id: request.run_id, + service_handle: lifecycleHandle("3"), + terminal_receipt_digest: terminal.receipt_digest, + }); + journal = appendComposedPhase(journal, "world_paused", { + receipt: pause, receipt_digest: pause.receipt_digest, run_id: request.run_id, + }, "2026-01-01T00:00:12.000Z"); + const evidence = createComposedWorldEvidenceReceipt({ + export_handle: lifecycleHandle("7"), + inventory: [ + { authority: "actions", bytes: 1, path: "actions/log.jsonl", sha256: lifecycleDigest("a") }, + { authority: "checkpoints", bytes: 2, path: "checkpoints/final.json", sha256: lifecycleDigest("b") }, + { authority: "projections", bytes: 3, path: "projections/world.json", sha256: lifecycleDigest("c") }, + ], + pause_receipt_digest: pause.receipt_digest, + run_id: request.run_id, + source_service_handle: pause.service_handle, + }); + return appendComposedPhase(journal, "world_evidence_exported", { + evidence, receipt_digest: evidence.receipt_digest, run_id: request.run_id, + }, "2026-01-01T00:00:13.000Z"); +}; + +let lifecycleTimestampSequence = COMPOSED_RUN_PHASES.length; + +export const lifecyclePhaseContext = (input: Readonly<{ + afterPhase?: (phase: Parameters>[0]) => void; + persisted?: ComposedPhaseJournal[]; +}> = {}) => { + const persisted = input.persisted ?? []; + return Object.freeze({ + context: { + fault_injector: input.afterPhase ? { afterPhase: input.afterPhase } : undefined, + now: () => new Date(Date.UTC( + 2026, 0, 1, 0, 0, lifecycleTimestampSequence++, + )).toISOString(), + persist: (journal: ComposedPhaseJournal) => { persisted.push(journal); }, + }, + persisted, + }); +}; diff --git a/src/compose/liveEvidence.test.ts b/src/compose/liveEvidence.test.ts new file mode 100644 index 0000000..031c5fe --- /dev/null +++ b/src/compose/liveEvidence.test.ts @@ -0,0 +1,71 @@ +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { stableStringify } from "../ledger/stable.js"; +import { deriveComposedLiveEvidence } from "./liveEvidence.js"; +import { COMPOSED_ARTIFACT_ROLES, createComposedRunRecord } from "./runRecord.js"; + +const bytes = (value: unknown): Uint8Array => new TextEncoder().encode(`${stableStringify(value)}\n`); +const record = async (counts: Readonly>) => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-live-evidence-")); + const out = path.join(root, "run-live"); + const writer = await createComposedRunRecord({ identity: { + contract_versions: {}, created_at: "2026-08-07T12:00:00.000Z", run_id: "run-live", + }, out_dir: out }); + for (const [index, role] of COMPOSED_ARTIFACT_ROLES.entries()) { + await writer.writeArtifact({ bytes: bytes({ role }), path: `required/${role}-${index}.json`, role }); + } + const principals = Object.keys(counts).sort().map((participant) => ({ + participant, principal: `principal:${participant}`, + })); + const actions = principals.flatMap(({ participant, principal }) => + Array.from({ length: counts[participant]! }, (_, index) => ({ + authenticated: true, disposition: "applied", participant, principal, + receipt_id: `world-act-${participant}-${index + 1}`, + }))); + await writer.writeArtifact({ bytes: bytes({ principals, run_id: "run-live", + version: "simfile.composed-principals.v1" }), path: "identities/principals.json", + role: "identity" }); + await writer.writeArtifact({ bytes: bytes({ actions, run_id: "run-live", + version: "simfile.accepted-strategic-actions.v1" }), + path: "raw/world/accepted-strategic-actions.json", role: "accepted-action" }); + await writer.seal(); + return out; +}; + +describe("post-hoc live evidence", () => { + it("counts authenticated accepted actions for every declared principal", async () => { + const runDir = await record({ blue: 1, red: 2 }); + const verdict = await deriveComposedLiveEvidence({ + accepted_actions_path: "raw/world/accepted-strategic-actions.json", + principals_path: "identities/principals.json", run_dir: runDir, + }); + assert.equal(verdict.state, "passed"); + assert.deepEqual(verdict.counts.map(({ count, participant }) => ({ count, participant })), [ + { count: 1, participant: "blue" }, { count: 2, participant: "red" }, + ]); + }); + + it("keeps a valid sealed run while failing the live-agent verdict on zero", async () => { + const runDir = await record({ blue: 0, red: 1 }); + const verdict = await deriveComposedLiveEvidence({ + accepted_actions_path: "raw/world/accepted-strategic-actions.json", + principals_path: "identities/principals.json", run_dir: runDir, + }); + assert.equal(verdict.state, "failed"); + assert.deepEqual(verdict.zero_action_principals, ["principal:blue"]); + }); + + it("rejects tamper and unauthenticated or unlisted actions", async () => { + const runDir = await record({ blue: 1 }); + await writeFile(path.join(runDir, "identities/principals.json"), "{}\n"); + await assert.rejects(deriveComposedLiveEvidence({ + accepted_actions_path: "raw/world/accepted-strategic-actions.json", + principals_path: "identities/principals.json", run_dir: runDir, + }), /mismatch/u); + }); +}); + diff --git a/src/compose/liveEvidence.ts b/src/compose/liveEvidence.ts new file mode 100644 index 0000000..c7916fe --- /dev/null +++ b/src/compose/liveEvidence.ts @@ -0,0 +1,111 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { verifyManifestArtifacts } from "../observe/artifacts.js"; +import { parseRunManifest } from "../observe/manifest.js"; +import { assertSecretFreeComposedJson } from "./json.js"; + +export interface PrincipalStrategicActionCount { + readonly count: number; + readonly participant: string; + readonly principal: string; +} +export interface ComposedLiveEvidenceVerdict { + readonly counts: readonly PrincipalStrategicActionCount[]; + readonly state: "passed" | "failed"; + readonly zero_action_principals: readonly string[]; +} + +const exact = (raw: unknown, keys: readonly string[], label: string): Record => { + assertSecretFreeComposedJson(raw); + if (raw === null || typeof raw !== "object" || Array.isArray(raw) + || Object.keys(raw).sort().join("\0") !== [...keys].sort().join("\0")) { + throw new TypeError(`composed ${label} evidence is invalid`); + } + return raw as Record; +}; +const text = (raw: unknown, label: string): string => { + if (typeof raw !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._:/-]{0,255}$/u.test(raw)) { + throw new TypeError(`composed ${label} evidence is invalid`); + } + return raw; +}; +const artifactRole = (inventory: unknown, artifactPath: string, role: string): void => { + const root = exact(inventory, ["artifacts", "run_id", "version"], "inventory"); + if (root.version !== "simfile.composed-run-inventory.v1" || !Array.isArray(root.artifacts)) { + throw new TypeError("composed inventory evidence is invalid"); + } + const entry = root.artifacts.find((candidate) => candidate !== null + && typeof candidate === "object" && !Array.isArray(candidate) + && (candidate as Record).path === artifactPath); + if (entry === undefined || exact(entry, ["path", "role", "sha256"], "inventory entry").role !== role) { + throw new TypeError(`composed ${role} evidence role is invalid`); + } +}; + +/** Derives live-agent counts only after seal from authenticated world evidence. */ +export const deriveComposedLiveEvidence = async (input: Readonly<{ + accepted_actions_path: string; + principals_path: string; + run_dir: string; +}>): Promise => { + const runDir = path.resolve(input.run_dir); + const manifest = parseRunManifest(JSON.parse( + await readFile(path.join(runDir, "manifest.json"), "utf8"), + ) as unknown); + const checks = await verifyManifestArtifacts(runDir, manifest.artifacts); + const failed = checks.find(({ ok }) => !ok); + if (failed !== undefined) throw new TypeError(`composed live evidence mismatch: ${failed.path}`); + const declared = new Set(manifest.artifacts.map(({ path: artifactPath }) => artifactPath)); + if (!declared.has("inventory.json") || !declared.has(input.accepted_actions_path) + || !declared.has(input.principals_path)) { + throw new TypeError("composed live evidence is not manifest-declared"); + } + const [inventory, principalsRaw, actionsRaw] = await Promise.all([ + readFile(path.join(runDir, "inventory.json"), "utf8").then((value) => JSON.parse(value) as unknown), + readFile(path.join(runDir, input.principals_path), "utf8").then((value) => JSON.parse(value) as unknown), + readFile(path.join(runDir, input.accepted_actions_path), "utf8").then((value) => JSON.parse(value) as unknown), + ]); + artifactRole(inventory, input.principals_path, "identity"); + artifactRole(inventory, input.accepted_actions_path, "accepted-action"); + const principalsRoot = exact(principalsRaw, ["principals", "run_id", "version"], "principal"); + const actionsRoot = exact(actionsRaw, ["actions", "run_id", "version"], "accepted action"); + if (principalsRoot.version !== "simfile.composed-principals.v1" + || actionsRoot.version !== "simfile.accepted-strategic-actions.v1" + || principalsRoot.run_id !== manifest.run_id || actionsRoot.run_id !== manifest.run_id + || !Array.isArray(principalsRoot.principals) || !Array.isArray(actionsRoot.actions) + || principalsRoot.principals.length < 1 || principalsRoot.principals.length > 4_096 + || actionsRoot.actions.length > 1_000_000) { + throw new TypeError("composed live evidence identity is invalid"); + } + const counts = principalsRoot.principals.map((raw) => { + const principal = exact(raw, ["participant", "principal"], "principal"); + return { count: 0, participant: text(principal.participant, "participant"), + principal: text(principal.principal, "principal") }; + }).sort((left, right) => left.principal.localeCompare(right.principal)); + if (new Set(counts.map(({ principal }) => principal)).size !== counts.length + || new Set(counts.map(({ participant }) => participant)).size !== counts.length) { + throw new TypeError("composed live evidence principals are duplicated"); + } + const byPrincipal = new Map(counts.map((entry) => [entry.principal, entry])); + const receipts = new Set(); + for (const raw of actionsRoot.actions) { + const action = exact(raw, ["authenticated", "disposition", "participant", "principal", + "receipt_id"], "accepted action"); + const principal = text(action.principal, "principal"); + const participant = text(action.participant, "participant"); + const receipt = text(action.receipt_id, "receipt"); + const count = byPrincipal.get(principal); + if (action.authenticated !== true || action.disposition !== "applied" + || count === undefined || count.participant !== participant || receipts.has(receipt)) { + throw new TypeError("composed accepted action evidence is invalid"); + } + receipts.add(receipt); count.count += 1; + } + const frozenCounts = Object.freeze(counts.map((entry) => Object.freeze({ ...entry }))); + const zero = Object.freeze(frozenCounts.filter(({ count }) => count === 0) + .map(({ principal }) => principal)); + return Object.freeze({ counts: frozenCounts, state: zero.length === 0 ? "passed" : "failed", + zero_action_principals: zero }); +}; + diff --git a/src/compose/liveViewerProjection.test.ts b/src/compose/liveViewerProjection.test.ts new file mode 100644 index 0000000..b0ffd98 --- /dev/null +++ b/src/compose/liveViewerProjection.test.ts @@ -0,0 +1,171 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import type { ComposedRunArtifactInput } from "./runRecord.js"; +import type { ComposedViewerBinding } from "./viewerBinding.js"; +import { createComposedLiveViewerProjection } from "./liveViewerProjection.js"; +import { digestComposedJson } from "./json.js"; + +const binding: ComposedViewerBinding = { + extensions: [{ id: "fixture-renderer", recorded_artifact: "presentation/final.json" }], + live_trace: { + artifact: { id: "viewer_trace", max_bytes: 4_096, + media_type: "application/json", path: "/tmp/spawnfile-public/viewer.json" }, + extension_id: "fixture-renderer", + }, + version: "simfile.composed-viewer-binding.v1", +}; +const trace = (ticks: readonly number[]) => Buffer.from(`${JSON.stringify({ + agents: [], corridors: [], ledger_facts: [], presence: [], + rooms: [{ id: "floor", kind: "square", label: "floor", members: [], + scale: [20, 10], scene: [0, 0, 0], scope: "world://floor" }], + run_id: "viewer-run", run_name: "viewer run", signals: [], + spatial_samples: ticks.map((tick) => ({ + ...(tick === 4 ? { discontinuities: ["ball"] } : {}), + occupancy: { floor: ["ball"] }, + objects: [{ id: "ball", position: [tick, 0], velocity: [1, 0] }], + tick, + transit: tick === 4 ? [{ agent: "ball", from_room: "floor", + path_id: "restart", ticks_remaining: 0, to_room: "floor" }] : [], + })), + tick_duration_ms: 20, version: "viewer.trace.v1", +})}\n`); +const source = (bytes: Uint8Array) => { + const request = { + artifact: binding.live_trace!.artifact, + descriptor_digest: `sha256:${"d".repeat(64)}`, + run_id: "viewer-run", + selected_target: { fingerprint: `sha256:${"1".repeat(32)}`, + handle: "opaque_1111111111111111" }, + version: "spawnfile.target-public-artifact-snapshot.request.v1", + world_service_handle: "opaque_2222222222222222", + }; + return { + artifact_id: "viewer_trace", + content_digest: `sha256:${createHash("sha256").update(bytes).digest("hex")}`, + media_type: "application/json", + request, + request_digest: digestComposedJson( + "spawnfile.target-public-artifact-snapshot.request.v1", request, + ), + response_version: "spawnfile.target-public-artifact-snapshot.v1" as const, + run_id: "viewer-run", + size_bytes: bytes.byteLength, + }; +}; + +describe("composed live viewer projection", () => { + it("mirrors exact snapshots and seals a monotonic generic frame prefix", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-composed-live-viewer-")); + try { + const projection = createComposedLiveViewerProjection({ + binding, run_id: "viewer-run", staging_dir: root, + }); + const first = trace([1, 2]); + const second = trace([1, 2, 4]); + await projection.publish(first, source(first)); + await projection.publish(second, source(second)); + const declaration = JSON.parse(await readFile( + path.join(root, "viewer-extension-data.json"), "utf8", + )) as { extensions: Array<{ path: string; sha256: string }> }; + const current = await readFile(path.join(root, declaration.extensions[0]!.path)); + assert.deepEqual(current, second); + assert.equal(createHash("sha256").update(current).digest("hex"), + declaration.extensions[0]!.sha256); + const artifacts: ComposedRunArtifactInput[] = []; + const result = await projection.finalize({ + writeArtifacts: (entries) => { artifacts.push(...entries); return Promise.resolve(); }, + }); + assert.deepEqual(result, { frontier_tick: 4, publications: 2 }); + const frames = Buffer.from(artifacts.find(({ path: value }) => + value === "raw/frames.jsonl")!.bytes).toString("utf8").trim().split("\n") + .map((line) => JSON.parse(line) as Record); + assert.deepEqual(frames.slice(1).map(({ tick }) => tick), [1, 2, 4]); + assert.deepEqual(frames.at(-1)?.discontinuities, ["ball"]); + assert.deepEqual(frames.at(-1)?.occupancy, { floor: ["ball"] }); + assert.deepEqual(frames.at(-1)?.transit, [{ agent: "ball", + from_room: "floor", path_id: "restart", ticks_remaining: 0, + to_room: "floor" }]); + const ledger = Buffer.from(artifacts.find(({ path: value }) => + value === "provenance/viewer-projection-sources.jsonl")!.bytes) + .toString("utf8").trim().split("\n").map((line) => JSON.parse(line) as { + content_digest: string; + request: Record; + request_digest: string; + snapshot_path: string; + }); + assert.equal(ledger.length, 2); + for (const row of ledger) { + assert.equal(row.request_digest, digestComposedJson( + "spawnfile.target-public-artifact-snapshot.request.v1", row.request, + )); + const snapshot = artifacts.find(({ path: value }) => value === row.snapshot_path); + assert.ok(snapshot); + assert.equal(row.content_digest, + `sha256:${createHash("sha256").update(snapshot.bytes).digest("hex")}`); + } + await assert.rejects(readFile(path.join(root, "viewer-extension-data.json"))); + await assert.rejects(projection.publish(second, source(second)), /closed/u); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("rejects identity and digest drift without poisoning a later publication", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-composed-live-viewer-")); + try { + const projection = createComposedLiveViewerProjection({ + binding, run_id: "viewer-run", staging_dir: root, + }); + const bytes = trace([1]); + await assert.rejects(projection.publish(bytes, { + ...source(bytes), content_digest: `sha256:${"b".repeat(64)}`, + }), /source is invalid/u); + await projection.publish(bytes, source(bytes)); + const stale = trace([0]); + await assert.rejects(projection.publish(stale, source(stale)), + /frontier regressed/u); + const terminalRaw = JSON.parse(bytes.toString("utf8")) as Record; + terminalRaw.playback_status = "completed"; + const terminal = Buffer.from(`${JSON.stringify(terminalRaw)}\n`); + await projection.publish(terminal, source(terminal)); + assert.deepEqual(await projection.finalize({ + writeArtifacts: () => Promise.resolve(), + }), { frontier_tick: 1, publications: 2 }); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); + + it("rolls back a rejected publication without sealing unledgered frame rows", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-composed-live-viewer-")); + try { + let failed = false; + const projection = createComposedLiveViewerProjection({ + binding, + dependencies: { before_write: (relative) => { + if (!failed && relative === "viewer-extension-data.json") { + failed = true; + throw new Error("injected declaration failure"); + } + } }, + run_id: "viewer-run", staging_dir: root, + }); + const bytes = trace([1]); + await assert.rejects(projection.publish(bytes, source(bytes)), + /injected declaration failure/u); + assert.deepEqual(await projection.finalize({ + writeArtifacts: () => Promise.resolve(), + }), { frontier_tick: -1, publications: 0 }); + await assert.rejects(readFile(path.join(root, "raw", "frames.jsonl")), /ENOENT/u); + await assert.rejects(readFile(path.join(root, "provenance", + "viewer-projection-sources.jsonl")), /ENOENT/u); + } finally { + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/src/compose/liveViewerProjection.ts b/src/compose/liveViewerProjection.ts new file mode 100644 index 0000000..3c755fe --- /dev/null +++ b/src/compose/liveViewerProjection.ts @@ -0,0 +1,391 @@ +import { createHash, randomUUID } from "node:crypto"; +import { + mkdir, + rename, + rm, + unlink, + writeFile, +} from "node:fs/promises"; +import path from "node:path"; + +import { assertSecretFreeComposedJson, digestComposedJson } from "./json.js"; +import type { ComposedViewerBinding } from "./viewerBinding.js"; +import type { ComposedRunRecord } from "./runRecord.js"; + +const FRAMES_PATH = "raw/frames.jsonl"; +const SOURCE_LEDGER_PATH = "provenance/viewer-projection-sources.jsonl"; +const LIVE_DECLARATION_PATH = "viewer-extension-data.json"; +const sha256 = (bytes: Uint8Array): string => + createHash("sha256").update(bytes).digest("hex"); +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); +const finitePair = (value: unknown): [number, number] | undefined => + Array.isArray(value) && value.length === 2 + && value.every((entry) => typeof entry === "number" && Number.isFinite(entry)) + ? [value[0] as number, value[1] as number] + : undefined; + +interface ProjectionObject { + readonly id: string; + readonly position: [number, number]; + readonly velocity: [number, number]; +} +interface ProjectionSample { + readonly discontinuities?: readonly string[]; + readonly objects: readonly ProjectionObject[]; + readonly occupancy: Readonly>; + readonly tick: number; + readonly transit: readonly Readonly<{ + agent: string; + from_room: string; + path_id: string; + ticks_remaining: number; + to_room: string; + }>[]; +} +interface ProjectionTrace { + readonly playback_status?: string; + readonly rooms: readonly unknown[]; + readonly run_id: string; + readonly samples: readonly ProjectionSample[]; + readonly tick_duration_ms: number; +} + +const parseTrace = (bytes: Uint8Array, runId: string): ProjectionTrace => { + const raw = JSON.parse(Buffer.from(bytes).toString("utf8")) as unknown; + if (!isRecord(raw) || raw.version !== "viewer.trace.v1" || raw.run_id !== runId + || typeof raw.run_name !== "string" + || !Array.isArray(raw.rooms) || !Array.isArray(raw.corridors) + || !Array.isArray(raw.agents) || !Array.isArray(raw.presence) + || !Array.isArray(raw.ledger_facts) || !Array.isArray(raw.signals) + || !Array.isArray(raw.spatial_samples)) { + throw new TypeError("composed live viewer trace is invalid"); + } + if (raw.playback_status !== undefined + && (typeof raw.playback_status !== "string" + || !["live", "completed", "failed"].includes(raw.playback_status))) { + throw new TypeError("composed live viewer playback status is invalid"); + } + if (raw.tick_duration_ms !== undefined + && (typeof raw.tick_duration_ms !== "number" + || !Number.isFinite(raw.tick_duration_ms) || raw.tick_duration_ms <= 0)) { + throw new TypeError("composed live viewer tick duration is invalid"); + } + const ticks = new Set(); + const samples = raw.spatial_samples.map((sample): ProjectionSample => { + if (!isRecord(sample) || !Number.isSafeInteger(sample.tick) + || (sample.tick as number) < 0 || ticks.has(sample.tick as number) + || (sample.objects !== undefined && !Array.isArray(sample.objects))) { + throw new TypeError("composed live viewer trace sample is invalid"); + } + ticks.add(sample.tick as number); + const discontinuities = sample.discontinuities; + if (discontinuities !== undefined && (!Array.isArray(discontinuities) + || discontinuities.some((id) => typeof id !== "string"))) { + throw new TypeError("composed live viewer trace sample is invalid"); + } + const occupancy = sample.occupancy; + if (!isRecord(occupancy) || Object.values(occupancy).some((ids) => + !Array.isArray(ids) || ids.some((id) => typeof id !== "string"))) { + throw new TypeError("composed live viewer trace sample is invalid"); + } + if (!Array.isArray(sample.transit)) { + throw new TypeError("composed live viewer trace sample is invalid"); + } + const transit = sample.transit.map((entry) => { + const ticksRemaining = isRecord(entry) ? entry.ticks_remaining : undefined; + if (!isRecord(entry) || typeof entry.agent !== "string" + || typeof entry.from_room !== "string" || typeof entry.path_id !== "string" + || !Number.isSafeInteger(ticksRemaining) || (ticksRemaining as number) < 0 + || typeof entry.to_room !== "string") { + throw new TypeError("composed live viewer trace transit is invalid"); + } + return Object.freeze({ + agent: entry.agent, from_room: entry.from_room, path_id: entry.path_id, + ticks_remaining: ticksRemaining as number, to_room: entry.to_room, + }); + }); + const ids = new Set(); + const objects = (sample.objects ?? []).map((object): ProjectionObject => { + if (!isRecord(object) || typeof object.id !== "string" || object.id.length === 0 + || ids.has(object.id)) { + throw new TypeError("composed live viewer trace object is invalid"); + } + const position = finitePair(object.position); + const velocity = object.velocity === undefined ? [0, 0] as [number, number] + : finitePair(object.velocity); + if (position === undefined || velocity === undefined) { + throw new TypeError("composed live viewer trace object is invalid"); + } + ids.add(object.id); + return Object.freeze({ id: object.id, position, velocity }); + }); + return Object.freeze({ + ...(discontinuities === undefined ? {} : { + discontinuities: Object.freeze([...discontinuities] as string[]), + }), + objects: Object.freeze(objects), + occupancy: Object.freeze(Object.fromEntries(Object.entries(occupancy).map( + ([room, ids]) => [room, Object.freeze([...(ids as string[])])], + ))), + tick: sample.tick as number, + transit: Object.freeze(transit), + }); + }); + const duration = raw.tick_duration_ms; + return Object.freeze({ + ...(typeof raw.playback_status === "string" + ? { playback_status: raw.playback_status } + : {}), + rooms: raw.rooms, + run_id: raw.run_id, + samples: Object.freeze(samples), + tick_duration_ms: typeof duration === "number" && Number.isFinite(duration) + && duration > 0 ? duration : 20, + }); +}; + +const boundsFor = (trace: ProjectionTrace): Readonly<{ + max: [number, number]; min: [number, number]; +}> => { + const extents: Array = []; + for (const room of trace.rooms) { + if (!isRecord(room) || !Array.isArray(room.scene) || room.scene.length < 2) continue; + const center = finitePair(room.scene.slice(0, 2)); + const scale = finitePair(room.scale); + if (center !== undefined && scale !== undefined && scale[0] > 0 && scale[1] > 0) { + extents.push([center[0] - scale[0] / 2, center[1] - scale[1] / 2, + center[0] + scale[0] / 2, center[1] + scale[1] / 2]); + } + } + if (extents.length === 0) { + for (const sample of trace.samples) { + for (const object of sample.objects) { + extents.push([object.position[0], object.position[1], + object.position[0], object.position[1]]); + } + } + } + if (extents.length === 0) return { max: [1, 1], min: [-1, -1] }; + const minX = Math.min(...extents.map((extent) => extent[0])); + const minY = Math.min(...extents.map((extent) => extent[1])); + const maxX = Math.max(...extents.map((extent) => extent[2])); + const maxY = Math.max(...extents.map((extent) => extent[3])); + return { + max: [maxX === minX ? maxX + 1 : maxX, maxY === minY ? maxY + 1 : maxY], + min: [maxX === minX ? minX - 1 : minX, maxY === minY ? minY - 1 : minY], + }; +}; + +const atomicWrite = async (target: string, bytes: Uint8Array): Promise => { + await mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); + const temporary = `${target}.${process.pid}.${randomUUID()}.pending`; + try { + await writeFile(temporary, bytes, { flag: "wx", mode: 0o600 }); + await rename(temporary, target); + } finally { + await unlink(temporary).catch(() => undefined); + } +}; + +export interface VerifiedViewerProjectionSource { + readonly artifact_id: string; + readonly content_digest: string; + readonly media_type: string; + readonly request: Readonly>; + readonly request_digest: string; + readonly response_version: "spawnfile.target-public-artifact-snapshot.v1"; + readonly run_id: string; + readonly size_bytes: number; +} + +export interface ComposedLiveViewerProjection { + finalize(record: Pick): Promise>; + publish(bytes: Uint8Array, source: VerifiedViewerProjectionSource): Promise; +} + +/** Mirrors authenticated public snapshots into the observer-only live transport. */ +export const createComposedLiveViewerProjection = (input: Readonly<{ + binding: ComposedViewerBinding; + dependencies?: Readonly<{ before_write?: (relativePath: string) => void }>; + run_id: string; + staging_dir: string; +}>): ComposedLiveViewerProjection => { + const live = input.binding.live_trace; + if (live === undefined) throw new TypeError("composed live viewer binding is absent"); + const framesPath = path.join(input.staging_dir, FRAMES_PATH); + const ledgerPath = path.join(input.staging_dir, SOURCE_LEDGER_PATH); + const declarationPath = path.join(input.staging_dir, LIVE_DECLARATION_PATH); + const stagedPaths = new Set(); + const snapshots: Array> = []; + let frontier = -1; + let accepting = true; + let declarationBytes: Uint8Array | undefined; + let framesBytes: Uint8Array | undefined; + let ledgerBytes: Uint8Array | undefined; + let publication = 0; + let pending: Promise = Promise.resolve(); + + const enqueue = (operation: () => Promise): Promise => { + const result = pending.then(operation, operation); + pending = result.catch(() => undefined); + return result; + }; + const removeTransientFiles = async (): Promise => { + // The declaration is the live generation commit marker. Remove it first + // so a new reader cannot discover paths while their snapshot is removed. + await rm(declarationPath, { force: true }); + await Promise.all([ + rm(framesPath, { force: true }), + rm(ledgerPath, { force: true }), + ...[...stagedPaths].map((relative) => + rm(path.join(input.staging_dir, relative), { force: true })), + ]); + }; + const writeLive = async (relative: string, bytes: Uint8Array): Promise => { + input.dependencies?.before_write?.(relative); + await atomicWrite(path.join(input.staging_dir, relative), bytes); + }; + const publish = async ( + bytes: Uint8Array, + source: VerifiedViewerProjectionSource, + ): Promise => { + if (!accepting) throw new TypeError("composed live viewer projection is closed"); + return enqueue(async () => { + const digest = sha256(bytes); + assertSecretFreeComposedJson(source.request); + const requestedArtifact = source.request.artifact; + if (source.artifact_id !== live.artifact.id + || source.media_type !== live.artifact.media_type + || source.run_id !== input.run_id + || source.response_version !== "spawnfile.target-public-artifact-snapshot.v1" + || source.size_bytes !== bytes.byteLength + || source.size_bytes > live.artifact.max_bytes + || source.content_digest !== `sha256:${digest}` + || source.request_digest !== digestComposedJson( + "spawnfile.target-public-artifact-snapshot.request.v1", source.request, + ) + || source.request.run_id !== input.run_id + || !isRecord(requestedArtifact) + || requestedArtifact.id !== live.artifact.id + || requestedArtifact.path !== live.artifact.path + || requestedArtifact.media_type !== live.artifact.media_type + || requestedArtifact.max_bytes !== live.artifact.max_bytes) { + throw new TypeError("composed live viewer projection source is invalid"); + } + const trace = parseTrace(bytes, input.run_id); + const maxTick = trace.samples.reduce((maximum, { tick }) => + maximum === undefined ? tick : Math.max(maximum, tick), undefined); + if (frontier >= 0 && (maxTick === undefined || maxTick < frontier)) { + throw new TypeError("composed live viewer projection frontier regressed"); + } + const current = publication + 1; + const relative = `provenance/viewer-snapshots/${String(current) + .padStart(6, "0")}.json`; + stagedPaths.add(relative); + const fresh = [...trace.samples] + .filter(({ tick }) => tick > frontier) + .sort((left, right) => left.tick - right.tick); + const header = framesBytes === undefined ? `${JSON.stringify({ + bounds: boundsFor(trace), + sim_seconds_per_tick: trace.tick_duration_ms / 1_000, + version: "simfile.dynamics-run-frames-header.v1", + })}\n` : Buffer.from(framesBytes).toString("utf8"); + const candidateFrontier = fresh.at(-1)?.tick ?? frontier; + const frameRows = fresh.map((sample) => `${JSON.stringify({ + ...(sample.discontinuities === undefined + ? {} : { discontinuities: sample.discontinuities }), + objects: sample.objects, + occupancy: sample.occupancy, + source_viewer_trace_sha256: digest, + tick: sample.tick, + transit: sample.transit, + version: "simfile.dynamics-run-frame.v1", + })}\n`).join(""); + const candidateFrames = Buffer.from(`${header}${frameRows}`); + const ledgerPrefix = ledgerBytes === undefined + ? "" : Buffer.from(ledgerBytes).toString("utf8"); + const candidateLedger = Buffer.from(`${ledgerPrefix}${JSON.stringify({ + artifact_id: source.artifact_id, + content_digest: source.content_digest, + extension_id: live.extension_id, + frontier_tick: candidateFrontier, + media_type: source.media_type, + playback_status: trace.playback_status ?? "live", + publication: current, + request: source.request, + request_digest: source.request_digest, + response: { + artifact_id: source.artifact_id, + content_digest: source.content_digest, + media_type: source.media_type, + request_digest: source.request_digest, + run_id: source.run_id, + size_bytes: source.size_bytes, + version: source.response_version, + }, + run_id: source.run_id, + sample_count: trace.samples.length, + size_bytes: source.size_bytes, + snapshot_path: relative, + version: "simfile.composed-viewer-projection-source.v1", + })}\n`); + const candidateDeclaration = Buffer.from(`${JSON.stringify({ + extensions: [{ id: live.extension_id, path: relative, sha256: digest }], + version: "simfile.viewer-extension-data.v1", + })}\n`); + const snapshotBytes = Uint8Array.from(bytes); + try { + await writeLive(relative, snapshotBytes); + await writeLive(SOURCE_LEDGER_PATH, candidateLedger); + await writeLive(FRAMES_PATH, candidateFrames); + await writeLive(LIVE_DECLARATION_PATH, candidateDeclaration); + } catch (error) { + await rm(path.join(input.staging_dir, relative), { force: true }) + .catch(() => undefined); + if (publication === 0) { + await Promise.all([framesPath, ledgerPath, declarationPath].map((target) => + rm(target, { force: true }).catch(() => undefined))); + } else { + await Promise.all([ + writeLive(SOURCE_LEDGER_PATH, ledgerBytes!), + writeLive(FRAMES_PATH, framesBytes!), + ]).catch(() => undefined); + await writeLive(LIVE_DECLARATION_PATH, declarationBytes!) + .catch(() => undefined); + } + throw error; + } + snapshots.push(Object.freeze({ bytes: snapshotBytes, path: relative })); + declarationBytes = candidateDeclaration; + framesBytes = candidateFrames; + frontier = candidateFrontier; + ledgerBytes = candidateLedger; + publication = current; + }); + }; + + return Object.freeze({ + finalize: async (record: Pick) => { + accepting = false; + await pending; + if (publication === 0 || framesBytes === undefined || ledgerBytes === undefined) { + await removeTransientFiles(); + return Object.freeze({ frontier_tick: frontier, publications: 0 }); + } + await removeTransientFiles(); + await record.writeArtifacts([ + { bytes: ledgerBytes, path: SOURCE_LEDGER_PATH, role: "provenance" }, + ...snapshots.map(({ bytes: snapshot, path: snapshotPath }) => ({ + bytes: snapshot, path: snapshotPath, role: "provenance" as const, + })), + { bytes: framesBytes, path: FRAMES_PATH, role: "presentation" }, + ]); + return Object.freeze({ frontier_tick: frontier, publications: publication }); + }, + publish, + }); +}; diff --git a/src/compose/phase-journal.test.ts b/src/compose/phase-journal.test.ts new file mode 100644 index 0000000..003c6de --- /dev/null +++ b/src/compose/phase-journal.test.ts @@ -0,0 +1,202 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + appendComposedPhase, + createComposedPhaseJournal, + markComposedJournalRecoverable, + parseComposedPhaseJournal, + readComposedPhaseJournal, + writeComposedPhaseJournal, +} from "./journal.js"; +import { composedRecoveryCommand } from "./receipt.js"; +import { COMPOSED_RUN_PHASES } from "./types.js"; + +const sha = (value: string): `sha256:${string}` => + `sha256:${value.repeat(64).slice(0, 64)}`; +const request = { + descriptor_digest: sha("a"), + mode: "dry-run", + organization: { + artifact_digest: sha("b"), source_digest: sha("c"), world_bindings_digest: sha("d"), + }, + required_world_capabilities: [], + run_id: "run-one", + source_digest: sha("e"), + target: { auth_profile: "simfile-live", selector: "gpu-4090" }, + version: "simfile.composed-run-request.v1", + world: { + artifact_manifest_digest: sha("f"), bundle_digest: sha("1"), + runtime_abi: "simfile.world-sidecar-runtime.v1", + }, +} as const; +const at = (index: number): string => `2026-08-07T00:00:${String(index).padStart(2, "0")}.000Z`; +const proof = (phase: string) => ({ proof_digest: sha(phase.charAt(0) || "a"), run_id: "run-one" }); +const execution = { + configuration: { + organization_expectation: { + deployment_name: "organization_unit", + member_engines: { member: "engine-v1" }, + moltnet_release: { + architecture: "amd64", + asset_sha256: sha("2"), + release_version: "v1.2.3", + source_revision: "3".repeat(40), + }, + selected_target_receipt_digest: sha("4"), + unit_id: "organization_unit_container", + world_binding_digest: request.organization.world_bindings_digest, + }, + readiness_expectation: { + artifact_digest: sha("5"), + bundle_digest: request.world.bundle_digest, + capability_manifest_digests: [sha("6")], + mechanics_sha256: sha("7"), + normalized_checkpoint_sha256: sha("8"), + run_id: request.run_id, + world_instance_id: "run-one-world", + }, + terminal_tick: 4, + topology_expectation: { + selected_target: { fingerprint: `sha256:${"9".repeat(32)}`, handle: "opaque_aaaaaaaaaaaaaaaa" }, + }, + }, + provider: { + compiled_output_directory: "/tmp/spawnfile-compiled", + evidence_destination_directory: "/tmp/spawnfile-evidence", + evidence_mount_path: "/var/lib/simfile/evidence", + lifecycle_invocations: { + down: "lci_down_aaaaaaaaaaaa", + export: "lci_export_aaaaaaaaaa", + up: "lci_up_aaaaaaaaaaaaaa", + }, + organization_handoff: { + env_file: "/tmp/runtime.env", + selected_target_receipt_file: "/tmp/selected-target.json", + world_bindings_file: "/tmp/world-bindings.json", + }, + organization_container_name: "organization_unit", + organization_image_tag: "organization-unit:run-one", + organization_path: "/tmp/organization.yaml", + spawnfile_bin: "/tmp/spawnfile/dist/cli/index.js", + spawnfile_cwd: "/tmp/spawnfile", + target_config_producer: { + args: [request.target.selector], + command: "/usr/local/bin/target-config-producer", + transport: "stdout_to_spawnfile_stdin", + }, + terminal_artifact: { + id: "terminal_receipt", + max_bytes: 131_072, + path: "/tmp/spawnfile-public/terminal.json", + }, + world_readiness_port: 8080, + }, + secret_bindings: [{ name: "provider_key", scope: "world", source_handle: "opaque_bbbbbbbbbbbbbbbb" }], + version: "simfile.composed-execution.v1", +} as const; + +test("phase journal advances monotonically, replays exactly, and restores byte truth", async () => { + let journal = createComposedPhaseJournal(request, at(0)); + const authority = journal.authority_digest; + const genesis = journal.genesis_nonce; + const prepared = proof("prepared"); + journal = appendComposedPhase(journal, "prepared", prepared, at(1)); + assert.equal(journal.authority_digest, authority); + assert.equal(journal.genesis_nonce, genesis); + assert.deepEqual(appendComposedPhase(journal, "prepared", prepared, at(2)), journal); + assert.throws(() => appendComposedPhase(journal, "prepared", { + ...prepared, proof_digest: sha("9"), + }, at(2)), /contradictory/u); + assert.throws(() => appendComposedPhase(journal, "world_started_paused", proof("world"), at(2)), /monotonic/u); + + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-compose-journal-")); + try { + const file = path.join(root, "journal.json"); + await writeComposedPhaseJournal(file, journal); + assert.deepEqual(await readComposedPhaseJournal(file), journal); + } finally { + await rm(root, { recursive: true, force: true }); + } +}); + +test("journal reaches complete only through every exact phase", () => { + let journal = createComposedPhaseJournal(request, at(0)); + for (let index = 1; index < COMPOSED_RUN_PHASES.length; index += 1) { + const phase = COMPOSED_RUN_PHASES[index]!; + journal = appendComposedPhase(journal, phase, proof(phase), at(index)); + } + assert.equal(journal.current_phase, "completed"); + assert.equal(journal.state, "complete"); + assert.equal(journal.entries.length, COMPOSED_RUN_PHASES.length); +}); + +test("recovery state binds the exact next phase and command", () => { + const journal = appendComposedPhase( + createComposedPhaseJournal(request, at(0)), + "prepared", + proof("prepared"), + at(1), + ); + const recovery = markComposedJournalRecoverable(journal, { + recovery_command: composedRecoveryCommand( + "/tmp/run-one/journal.json", journal.request.run_id, journal.authority_digest, + ), + signal: "SIGTERM", + }); + assert.equal(recovery.state, "recoverable"); + assert.equal(recovery.authority_digest, journal.authority_digest); + assert.equal(recovery.genesis_nonce, journal.genesis_nonce); + assert.equal(recovery.interruption?.next_phase, "world_created"); + assert.deepEqual(parseComposedPhaseJournal(recovery), recovery); +}); + +test("journal fails closed on stale, cross-run, tampered, contradictory, or secret-shaped data", () => { + const journal = appendComposedPhase( + createComposedPhaseJournal(request, at(0)), + "prepared", + proof("prepared"), + at(1), + ); + assert.throws(() => appendComposedPhase(journal, "world_created", { + proof_digest: sha("2"), run_id: "run-foreign", + }, at(2)), /run correlation/u); + assert.throws(() => appendComposedPhase(journal, "world_created", { + proof_digest: sha("2"), run_id: "run-one", detail: "token=private-value", + }, at(2)), /secret-shaped/u); + assert.throws(() => parseComposedPhaseJournal({ ...journal, journal_digest: sha("0") }), /digest/u); + assert.throws(() => parseComposedPhaseJournal({ + ...journal, + state: "recoverable", + interruption: null, + }), /contradictory/u); +}); + +test("journal durably binds only nonsecret execution inputs for exact restart", () => { + const journal = createComposedPhaseJournal(request, at(0), execution); + assert.deepEqual(journal.execution, execution); + assert.doesNotMatch(JSON.stringify(journal), /token=|Bearer |private_config/u); + assert.throws(() => createComposedPhaseJournal(request, at(0), { + ...execution, + provider: { + ...execution.provider, + target_config_producer: { + ...execution.provider.target_config_producer, + args: ["foreign-target"], + }, + }, + }), /correlation/u); + assert.throws(() => createComposedPhaseJournal(request, at(0), { + ...execution, + provider: { + ...execution.provider, + target_config_producer: { + ...execution.provider.target_config_producer, + command: "token=must-not-persist", + }, + }, + }), /secret-shaped/u); +}); diff --git a/src/compose/phase.ts b/src/compose/phase.ts new file mode 100644 index 0000000..e87b27c --- /dev/null +++ b/src/compose/phase.ts @@ -0,0 +1,44 @@ +import { + appendComposedPhase, + parseComposedPhaseJournal, + type ComposedPhaseJournal, +} from "./journal.js"; +import { + composedRunPhaseIndex, + type ComposedRunFaultInjector, + type ComposedRunPhase, +} from "./types.js"; + +export interface ComposedPhaseContext { + readonly now: () => string; + readonly persist: (journal: ComposedPhaseJournal) => void | Promise; + readonly fault_injector?: ComposedRunFaultInjector; +} + +export const composedPhaseReached = ( + journal: ComposedPhaseJournal, + phase: ComposedRunPhase, +): boolean => composedRunPhaseIndex(journal.current_phase) >= composedRunPhaseIndex(phase); + +export const composedPhasePayload = ( + rawJournal: unknown, + phase: ComposedRunPhase, +): Readonly> => { + const journal = parseComposedPhaseJournal(rawJournal); + const entry = journal.entries[composedRunPhaseIndex(phase)]; + if (entry?.phase !== phase) throw new TypeError(`composed phase ${phase} is unavailable`); + return entry.payload; +}; + +/** Persists a successful phase before exposing its fault-injection boundary. */ +export const commitComposedPhase = async ( + journal: ComposedPhaseJournal, + phase: ComposedRunPhase, + payload: Record, + context: ComposedPhaseContext, +): Promise => { + const next = appendComposedPhase(journal, phase, payload, context.now()); + await context.persist(next); + await context.fault_injector?.afterPhase?.(phase); + return next; +}; diff --git a/src/compose/preflight.test.ts b/src/compose/preflight.test.ts new file mode 100644 index 0000000..c89bb5a --- /dev/null +++ b/src/compose/preflight.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import type { DynamicsRunActionSourceDeclaration } from "../dynamics/runActionSource.js"; +import { parseSimfileSource } from "../schema/parse.js"; +import { lifecycleRequest } from "./lifecycle.test-helper.js"; +import { runPreflightedComposedRun } from "./preflight.js"; +import { createComposedRunHarness } from "./run.test-helper.js"; + +const source = (config = ""): string => `simfile_version: "0.1" +name: composed-preflight +spawnfile: ./Spawnfile +clock: + seed: composed-preflight + tick: 1s +dynamics: + module: ./systems/world.ts + config:${config || " {}"} +`; + +const scriptedSource: DynamicsRunActionSourceDeclaration = Object.freeze({ + id: "local-script", + live_acceptance: false, + onTick: () => undefined, + participants: Object.freeze(["red"]), + provenance: "scripted", + version: "simfile.dynamics-run-action-source.v1", +}); + +test("composed preflight rejects every scripted input before owner or journal mutation", async () => { + const safe = parseSimfileSource(source(), { path: "Simfile" }).simfile; + const cases = [ + { + expected: /action sources/u, + input: { action_source: scriptedSource, simfile: safe }, + name: "dynamics action source", + }, + { + expected: /scripted-controller config/u, + input: { + simfile: parseSimfileSource(source("\n scripted_controller: {}"), { + path: "Simfile", + }).simfile, + }, + name: "scripted_controller config", + }, + { + expected: /scripted-controller config/u, + input: { + simfile: parseSimfileSource(source("\n scripted-controller: {}"), { + path: "Simfile", + }).simfile, + }, + name: "scripted-controller config", + }, + { + expected: /--acts/u, + input: { acts_path: "/tmp/local-acts.json", simfile: safe }, + name: "CLI --acts", + }, + ] as const; + + for (const item of cases) { + const directory = await mkdtemp(path.join(tmpdir(), "simfile-composed-preflight-")); + try { + const request = lifecycleRequest({ run_id: `run-${item.name.replaceAll(/[^a-z]+/gu, "-")}` }); + const harness = createComposedRunHarness(request); + const journalPath = path.join(directory, "journal.json"); + await assert.rejects(runPreflightedComposedRun({ + configuration: harness.configuration, + decision_inputs: item.input, + journal_path: journalPath, + ports: harness.ports, + request, + }), item.expected, item.name); + assert.deepEqual(harness.telemetry.calls, [], item.name); + assert.equal(harness.telemetry.participant_actions, 0, item.name); + assert.equal( + Object.values(harness.telemetry.effect_counts).every((count) => count === 0), + true, + item.name, + ); + await assert.rejects(access(journalPath), { code: "ENOENT" }, item.name); + } finally { + await rm(directory, { force: true, recursive: true }); + } + } +}); diff --git a/src/compose/preflight.ts b/src/compose/preflight.ts new file mode 100644 index 0000000..87f9487 --- /dev/null +++ b/src/compose/preflight.ts @@ -0,0 +1,44 @@ +import type { DynamicsRunActionSourceDeclaration } from "../dynamics/runActionSource.js"; +import type { Simfile } from "../schema/model.js"; +import { + runDurableComposedRun, + type ComposedRunOutcome, + type DurableComposedRunInput, +} from "./recovery.js"; + +export interface ComposedDecisionInputPreflight { + readonly action_source?: DynamicsRunActionSourceDeclaration; + readonly acts_path?: string; + readonly simfile: Simfile; +} + +export interface PreflightedComposedRunInput extends DurableComposedRunInput { + readonly decision_inputs: ComposedDecisionInputPreflight; +} + +const scriptedConfigKeys = ["scripted-controller", "scripted_controller"] as const; + +/** Rejects local/scripted decision inputs before any composed lifecycle authority is opened. */ +export const assertComposedDecisionInputs = ( + input: ComposedDecisionInputPreflight, +): void => { + if (input.action_source !== undefined) { + throw new TypeError("composed mode rejects dynamics run action sources"); + } + const config = input.simfile.dynamics?.config; + if (config !== undefined && scriptedConfigKeys.some((key) => Object.hasOwn(config, key))) { + throw new TypeError("composed mode rejects scripted-controller config"); + } + if (input.acts_path !== undefined) { + throw new TypeError("composed mode rejects --acts input"); + } +}; + +/** Public new-run seam: preflight is synchronous and precedes journal or owner mutation. */ +export const runPreflightedComposedRun = async ( + input: PreflightedComposedRunInput, +): Promise => { + assertComposedDecisionInputs(input.decision_inputs); + const { decision_inputs: _decisionInputs, ...durable } = input; + return runDurableComposedRun(durable); +}; diff --git a/src/compose/projectBinding.ts b/src/compose/projectBinding.ts new file mode 100644 index 0000000..3d40d92 --- /dev/null +++ b/src/compose/projectBinding.ts @@ -0,0 +1,150 @@ +import { z } from "zod"; + +import type { ComposedReplayAdapter } from "./replay.js"; +import type { ComposedArtifactRole } from "./runRecord.js"; +import { + parseComposedViewerBinding, + type ComposedViewerBinding, +} from "./viewerBinding.js"; +import { + parseRunnableWorldSidecarManifest, + type RunnableWorldSidecarBundle, +} from "../world-artifact/runnableBundle.js"; +import { + verifyWorldSidecarReadiness, + type WorldSidecarReadinessExpectation, +} from "../world-artifact/readiness.js"; + +export const COMPOSED_PROJECT_BINDING_VERSION = + "simfile.composed-project-binding.v1" as const; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const identifier = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); +const environment = z.string().regex(/^[A-Z][A-Z0-9]*(?:_[A-Z0-9]+)+$/u); +const relativePath = z.string().max(4_096).refine((value) => + value.length > 0 && !value.startsWith("/") && !value.includes("\\") + && value.split("/").every((part) => part.length > 0 && part !== "." && part !== "..")); +const credential = z.discriminatedUnion("kind", [ + z.object({ bytes: z.number().int().min(16).max(128), env: environment, + kind: z.literal("generated-token"), name: identifier }).strict(), + z.object({ content: z.unknown(), env: environment, + kind: z.literal("derived-config"), name: identifier }).strict(), +]); +const member = z.object({ + capability_manifest: z.unknown(), + id: identifier, + principal_id: z.string().min(1).max(256), + token_credential_name: identifier, +}).strict(); +const binding = z.object({ + credential_name: identifier, + name: identifier, + scope: identifier, +}).strict(); +const evidence = z.object({ + path: relativePath, + role: z.enum([ + "accepted-action", "action-result", "authority-export", "identity", + "presentation", "probe", "provenance", "terminal", "world-checkpoint", + "world-frame", + ]), + source: relativePath, +}).strict(); + +export interface PrepareComposedProjectInput { + readonly base_image_config_digest: string; + readonly evidence_root: string; + readonly internal_port: number; + readonly organization_container_name: string; + readonly platform: Readonly<{ architecture: "amd64" | "arm64"; os: "linux" }>; + readonly run_id: string; + readonly seed: string; + readonly secret_root: string; + readonly simfile_path: string; + readonly spawnfile_path: string; +} + +export interface ComposedProjectPreparation { + readonly base_image_config_digest: string; + readonly bundle: RunnableWorldSidecarBundle; + readonly credentials: readonly z.infer[]; + readonly evidence_artifacts: readonly Readonly<{ + path: string; + role: ComposedArtifactRole; + source: string; + }>[]; + readonly platform: Readonly<{ architecture: "amd64" | "arm64"; os: "linux" }>; + readonly readiness_expectation: WorldSidecarReadinessExpectation; + readonly replay_adapter: ComposedReplayAdapter; + readonly secret_bindings: readonly z.infer[]; + readonly terminal_tick: number; + readonly viewer?: ComposedViewerBinding; + readonly world_members: readonly z.infer[]; +} + +export interface ComposedProjectBinding { + readonly version: typeof COMPOSED_PROJECT_BINDING_VERSION; + prepareComposedProject( + input: PrepareComposedProjectInput, + ): Promise; +} + +const parsePreparation = ( + value: ComposedProjectPreparation, + input: PrepareComposedProjectInput, +): ComposedProjectPreparation => { + const manifest = parseRunnableWorldSidecarManifest(value.bundle.manifest); + if (manifest.digest !== value.readiness_expectation.bundle_digest) { + throw new TypeError("composed project bundle readiness is invalid"); + } + verifyWorldSidecarReadiness({ + ...value.readiness_expectation, + clock: { next_tick: 0, state: "paused" }, + decisions: { count: 0, phase: "open" }, + runtime_abi: manifest.runtime_abi, + status: "ready", + version: "simfile.world-sidecar-readiness.v1", + }, value.readiness_expectation); + const credentials = z.array(credential).min(1).max(64).parse(value.credentials); + const members = z.array(member).min(1).max(64).parse(value.world_members); + const bindings = z.array(binding).min(1).max(64).parse(value.secret_bindings); + const artifacts = z.array(evidence).min(1).max(64).parse(value.evidence_artifacts); + const viewer = parseComposedViewerBinding(value.viewer, artifacts); + const names = new Set(credentials.map(({ name }) => name)); + if (new Set(credentials.map(({ env }) => env)).size !== credentials.length + || names.size !== credentials.length + || members.some(({ token_credential_name: name }) => !names.has(name)) + || bindings.some(({ credential_name: name }) => !names.has(name)) + || new Set(artifacts.map(({ path: artifactPath }) => artifactPath)).size !== artifacts.length + || typeof value.replay_adapter?.restore !== "function" + || typeof value.replay_adapter.inject !== "function" + || typeof value.replay_adapter.finish !== "function" + || value.readiness_expectation.run_id !== input.run_id + || input.seed.length < 1 || input.seed.length > 4_096 + || value.base_image_config_digest !== input.base_image_config_digest + || value.platform.architecture !== input.platform.architecture + || value.platform.os !== input.platform.os + || !Number.isSafeInteger(value.terminal_tick) || value.terminal_tick < 1 + || !digest.safeParse(value.base_image_config_digest).success) { + throw new TypeError("composed project preparation is invalid"); + } + return Object.freeze({ ...value, credentials: Object.freeze(credentials), + evidence_artifacts: Object.freeze(artifacts), secret_bindings: Object.freeze(bindings), + ...(viewer === undefined ? {} : { viewer }), + world_members: Object.freeze(members) }); +}; + +/** Creates the host-only fixture declaration seam; it owns no lifecycle operation. */ +export const createComposedProjectBinding = ( + input: Pick, +): ComposedProjectBinding => { + if (typeof input.prepareComposedProject !== "function") { + throw new TypeError("composed project binding prepare function is invalid"); + } + return Object.freeze({ + prepareComposedProject: async (request: PrepareComposedProjectInput) => parsePreparation( + await input.prepareComposedProject(request), request, + ), + version: COMPOSED_PROJECT_BINDING_VERSION, + }); +}; diff --git a/src/compose/receipt.ts b/src/compose/receipt.ts new file mode 100644 index 0000000..73b5c1c --- /dev/null +++ b/src/compose/receipt.ts @@ -0,0 +1,181 @@ +import path from "node:path"; + +import { z } from "zod"; + +import { + assertSecretFreeComposedJson, + canonicalComposedJson, + digestComposedJson, +} from "./json.js"; +import { createComposedRunRequestDigest, type ComposedRunRequest } from "./request.js"; +import { COMPOSED_RUN_PHASES } from "./types.js"; + +export const COMPOSED_TERMINAL_RECEIPT_VERSION = + "simfile.composed-terminal-receipt.v1" as const; +export const COMPOSED_RECOVERY_RECEIPT_VERSION = + "simfile.composed-recovery-receipt.v1" as const; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const runId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); +const identifier = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); +const opaqueHandle = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); +const absolutePath = z.string().max(4_096).refine((value) => + path.isAbsolute(value) && path.normalize(value) === value && value !== path.parse(value).root); +const selectedTarget = z.object({ + fingerprint: z.string().regex(/^sha256:[a-f0-9]{32}$/u), + handle: opaqueHandle, +}).strict(); +const evidence = z.object({ + authority: identifier, + digest, + item_count: z.number().int().min(0).max(1_000_000), + state: z.literal("exported"), +}).strict(); + +export const composedTerminalReceiptSchema = z.object({ + cleanup: z.object({ + receipt_digest: digest, + remaining_owned_resources: z.array(opaqueHandle).max(64), + state: z.literal("cleaned"), + }).strict(), + evidence: z.object({ + organization: evidence, + world: evidence, + }).strict(), + journal_digest: digest, + receipt_digest: digest, + request_digest: digest, + run_id: runId, + seal: z.object({ digest, state: z.literal("sealed") }).strict(), + status: z.literal("completed"), + target: z.object({ + preparation_receipt_digest: digest, + selected_target: selectedTarget, + selector: identifier, + }).strict(), + topology: z.object({ + activation_receipt_digest: digest, + request_digest: digest, + receipt_digest: digest, + }).strict(), + verdict: z.object({ digest, state: z.enum(["valid", "invalid"]) }).strict(), + version: z.literal(COMPOSED_TERMINAL_RECEIPT_VERSION), +}).strict().superRefine((value, context) => { + if (value.cleanup.remaining_owned_resources.length > 0) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "completed receipt retains resources" }); + } +}); + +export const composedRecoveryReceiptSchema = z.object({ + authority_digest: digest, + journal_digest: digest, + journal_path: absolutePath, + next_phase: z.enum(COMPOSED_RUN_PHASES), + preserved_evidence: z.boolean(), + receipt_digest: digest, + recovery_command: z.string().min(1).max(8_192), + run_id: runId, + signal: z.enum(["SIGINT", "SIGTERM", "restart", "failure"]), + status: z.literal("recovery_required"), + version: z.literal(COMPOSED_RECOVERY_RECEIPT_VERSION), +}).strict(); + +export type ComposedTerminalReceipt = z.infer; +export type ComposedRecoveryReceipt = z.infer; + +const shellQuote = (value: string): string => `'${value.replaceAll("'", `'"'"'`)}'`; + +export const composedRecoveryCommand = ( + journalPath: string, + runIdValue: string, + authorityDigest: string, +): string => { + const checkedPath = absolutePath.parse(journalPath); + const checkedRun = runId.parse(runIdValue); + const checkedAuthority = digest.parse(authorityDigest); + return `simfile recover --journal ${shellQuote(checkedPath)} --run-id ${shellQuote(checkedRun)} --authority-digest ${shellQuote(checkedAuthority)}`; +}; + +const parseDigested = ( + raw: unknown, + schema: z.ZodType, + domain: string, +): Value => { + assertSecretFreeComposedJson(raw); + const value = schema.parse(raw); + const { receipt_digest: _receiptDigest, ...body } = value; + if (value.receipt_digest !== digestComposedJson(domain, body)) { + throw new TypeError("composed receipt digest is invalid"); + } + return Object.freeze(value); +}; + +export const parseComposedTerminalReceipt = (raw: unknown): ComposedTerminalReceipt => + parseDigested(raw, composedTerminalReceiptSchema, COMPOSED_TERMINAL_RECEIPT_VERSION); + +export const parseComposedRecoveryReceipt = (raw: unknown): ComposedRecoveryReceipt => { + const receipt = parseDigested( + raw, composedRecoveryReceiptSchema, COMPOSED_RECOVERY_RECEIPT_VERSION, + ); + if (receipt.recovery_command !== composedRecoveryCommand( + receipt.journal_path, receipt.run_id, receipt.authority_digest, + )) { + throw new TypeError("composed recovery receipt command is invalid"); + } + return receipt; +}; + +const sealReceipt = >( + domain: string, + body: Value, +): Value & { receipt_digest: string } => ({ + ...body, + receipt_digest: digestComposedJson(domain, body), +}); + +export const createComposedTerminalReceipt = ( + body: Omit & { + readonly request: ComposedRunRequest; + }, +): ComposedTerminalReceipt => { + const { request, ...values } = body; + return parseComposedTerminalReceipt(sealReceipt(COMPOSED_TERMINAL_RECEIPT_VERSION, { + ...values, + request_digest: createComposedRunRequestDigest(request), + run_id: request.run_id, + status: "completed", + version: COMPOSED_TERMINAL_RECEIPT_VERSION, + })); +}; + +export const createComposedRecoveryReceipt = ( + body: Omit, +): ComposedRecoveryReceipt => parseComposedRecoveryReceipt(sealReceipt( + COMPOSED_RECOVERY_RECEIPT_VERSION, + { + ...body, + recovery_command: composedRecoveryCommand( + body.journal_path, body.run_id, body.authority_digest, + ), + status: "recovery_required", + version: COMPOSED_RECOVERY_RECEIPT_VERSION, + }, +)); + +export const verifyComposedTerminalReceipt = ( + raw: unknown, + request: ComposedRunRequest, + expectedJournalDigest?: string, +): ComposedTerminalReceipt => { + const receipt = parseComposedTerminalReceipt(raw); + if (receipt.run_id !== request.run_id + || receipt.request_digest !== createComposedRunRequestDigest(request) + || (expectedJournalDigest !== undefined && receipt.journal_digest !== expectedJournalDigest)) { + throw new TypeError("composed terminal receipt correlation is invalid"); + } + return receipt; +}; + +export const serializeComposedReceipt = ( + raw: ComposedTerminalReceipt | ComposedRecoveryReceipt, +): string => `${canonicalComposedJson(raw)}\n`; diff --git a/src/compose/recovery.test.ts b/src/compose/recovery.test.ts new file mode 100644 index 0000000..407a911 --- /dev/null +++ b/src/compose/recovery.test.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createComposedPhaseJournal, writeComposedPhaseJournal } from "./journal.js"; +import { lifecycleRequest } from "./lifecycle.test-helper.js"; +import { + ComposedRunInterruption, + runComposedRecoveryCommand, + runDurableComposedRun, +} from "./recovery.js"; +import { composedRecoveryCommand } from "./receipt.js"; +import { createComposedRunHarness } from "./run.test-helper.js"; +import { + COMPOSED_RUN_PHASES, + composedRunPhaseIndex, + nextComposedRunPhase, + type ComposedRunPhase, +} from "./types.js"; + +const clock = () => { + let tick = 0; + return () => new Date(Date.UTC(2026, 2, 1, 0, 0, tick++)).toISOString(); +}; + +test("every durable phase interruption resumes through its exact command", async () => { + for (const [index, phase] of COMPOSED_RUN_PHASES.entries()) { + const directory = await mkdtemp(path.join(tmpdir(), "simfile-composed-recovery-")); + try { + const request = lifecycleRequest({ run_id: `run-recovery-${index}` }); + const harness = createComposedRunHarness(request); + const journalPath = path.join(directory, "journal.json"); + const now = clock(); + let injected = false; + const signal = index % 2 === 0 ? "SIGINT" as const : "SIGTERM" as const; + const interrupted = await runDurableComposedRun({ + configuration: harness.configuration, + fault_injector: { + afterPhase: (current) => { + if (!injected && current === phase) { + injected = true; + throw new ComposedRunInterruption(signal); + } + }, + }, + journal_path: journalPath, + now, + ports: harness.ports, + request, + }); + if (phase === "completed") { + assert.equal(interrupted.receipt.status, "completed"); + } else { + assert.equal(interrupted.receipt.status, "recovery_required", phase); + assert.equal(interrupted.journal.current_phase, phase, phase); + assert.equal(interrupted.journal.interruption?.next_phase, nextComposedRunPhase(phase)); + assert.equal(interrupted.receipt.signal, signal, phase); + assert.equal( + interrupted.receipt.preserved_evidence, + composedRunPhaseIndex(phase) >= composedRunPhaseIndex("world_evidence_exported"), + phase, + ); + assert.equal( + interrupted.receipt.recovery_command, + composedRecoveryCommand( + journalPath, interrupted.journal.request.run_id, interrupted.journal.authority_digest, + ), + phase, + ); + } + const recovered = await runComposedRecoveryCommand({ + argv: ["recover", "--journal", journalPath, + "--run-id", interrupted.journal.request.run_id, + "--authority-digest", interrupted.journal.authority_digest], + configuration: harness.configuration, + now, + ports: harness.ports, + }); + assert.equal(recovered.receipt.status, "completed", phase); + assert.equal(recovered.journal.state, "complete", phase); + assert.deepEqual(recovered.journal.entries.map((entry) => entry.phase), COMPOSED_RUN_PHASES); + assert.equal( + Object.values(harness.telemetry.effect_counts).every((count) => count === 1), true, phase, + ); + assert.equal(Object.keys(harness.telemetry.effect_counts).length, 13, phase); + assert.equal(harness.telemetry.activation_publications, 1, phase); + assert.equal(harness.telemetry.participant_actions, 0, phase); + } finally { + await rm(directory, { force: true, recursive: true }); + } + } +}); + +test("an active journal from a stopped process resumes without skipping gates", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "simfile-composed-restart-")); + try { + const request = lifecycleRequest({ run_id: "run-process-restart" }); + const harness = createComposedRunHarness(request); + const journalPath = path.join(directory, "journal.json"); + const initial = createComposedPhaseJournal(request, "2026-03-01T00:00:00.000Z"); + await writeComposedPhaseJournal(journalPath, initial); + const outcome = await runComposedRecoveryCommand({ + argv: ["recover", "--journal", journalPath, "--run-id", request.run_id, + "--authority-digest", initial.authority_digest], + configuration: harness.configuration, + now: clock(), + ports: harness.ports, + }); + assert.equal(outcome.receipt.status, "completed"); + assert.equal(outcome.journal.entries.length, COMPOSED_RUN_PHASES.length); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test("the process signal handler interrupts only after a persisted phase", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "simfile-composed-signal-")); + try { + const request = lifecycleRequest({ run_id: "run-real-signal" }); + const harness = createComposedRunHarness(request); + let emitted = false; + const outcome = await runDurableComposedRun({ + configuration: harness.configuration, + fault_injector: { + afterPhase: (phase: ComposedRunPhase) => { + if (!emitted && phase === "prepared") { + emitted = true; + process.emit("SIGINT"); + } + }, + }, + journal_path: path.join(directory, "journal.json"), + now: clock(), + ports: harness.ports, + request, + }); + assert.equal(outcome.receipt.status, "recovery_required"); + assert.equal(outcome.journal.current_phase, "prepared"); + assert.equal(outcome.receipt.signal, "SIGINT"); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +test("the recovery command parser rejects ambiguous arguments", async () => { + const request = lifecycleRequest(); + const harness = createComposedRunHarness(request); + await assert.rejects(runComposedRecoveryCommand({ + argv: ["recover", "--journal", "/tmp/a", "--force"], + configuration: harness.configuration, + ports: harness.ports, + }), /usage: simfile recover/u); +}); diff --git a/src/compose/recovery.ts b/src/compose/recovery.ts new file mode 100644 index 0000000..53ebdd0 --- /dev/null +++ b/src/compose/recovery.ts @@ -0,0 +1,240 @@ +import path from "node:path"; + +import { + createComposedPhaseJournal, + markComposedJournalRecoverable, + type ComposedPhaseJournal, +} from "./journal.js"; +import { + createComposedJournalSession, + openComposedJournalSession, + type ComposedJournalAuthorityExpectation, + type ComposedJournalSession, +} from "./journalSession.js"; +import { composedPhaseReached } from "./phase.js"; +import { + composedRecoveryCommand, + createComposedRecoveryReceipt, + type ComposedRecoveryReceipt, +} from "./receipt.js"; +import { + executeComposedRun, + completedComposedRunFromJournal, + type CompletedComposedRun, + type ComposedRunConfiguration, + type ComposedRunPorts, +} from "./run.js"; +import { + createComposedRunRequestDigest, + type ComposedRunRequest, +} from "./request.js"; +import type { ComposedRunFaultInjector } from "./types.js"; +import type { ComposedExecution } from "./execution.js"; + +export type ComposedInterruptionSignal = "SIGINT" | "SIGTERM"; + +export class ComposedRunInterruption extends Error { + readonly signal: ComposedInterruptionSignal; + + constructor(signal: ComposedInterruptionSignal) { + super(`composed run interrupted by ${signal}`); + this.name = "ComposedRunInterruption"; + this.signal = signal; + } +} + +export interface RecoverableComposedRun { + readonly journal: ComposedPhaseJournal; + readonly receipt: ComposedRecoveryReceipt; +} + +export type ComposedRunOutcome = CompletedComposedRun | RecoverableComposedRun; + +export interface DurableComposedRunInput { + readonly configuration: ComposedRunConfiguration; + readonly execution?: ComposedExecution; + readonly expected_authority?: ComposedJournalAuthorityExpectation; + readonly fault_injector?: ComposedRunFaultInjector; + readonly journal_path: string; + readonly journal_session?: ComposedJournalSession; + readonly now?: () => string; + readonly ports: ComposedRunPorts; + readonly request?: ComposedRunRequest; +} + +const missing = (error: unknown): boolean => + (error as NodeJS.ErrnoException | undefined)?.code === "ENOENT"; +const sameAuthority = ( + journal: ComposedPhaseJournal, + expected: ComposedJournalAuthorityExpectation, +): boolean => journal.authority_digest === expected.authority_digest + && journal.request.run_id === expected.run_id; + +const loadOrCreate = async ( + input: DurableComposedRunInput, + now: () => string, +): Promise<{ created: boolean; journal: ComposedPhaseJournal; session: ComposedJournalSession }> => { + if (input.journal_session !== undefined) { + if (input.journal_session.path !== input.journal_path) { + throw new TypeError("composed journal session path changed"); + } + await input.journal_session.assertCurrent(); + const journal = input.journal_session.current(); + if (input.expected_authority !== undefined + && !sameAuthority(journal, input.expected_authority)) { + throw new TypeError("composed journal authority changed"); + } + if (input.request !== undefined + && journal.request_digest !== createComposedRunRequestDigest(input.request)) { + throw new TypeError("durable composed request changed"); + } + return { created: false, journal, session: input.journal_session }; + } + if (input.expected_authority === undefined) { + if (input.request === undefined) { + throw new TypeError("composed journal authority expectation is unavailable"); + } + const journal = createComposedPhaseJournal(input.request, now(), input.execution); + const session = await createComposedJournalSession(input.journal_path, journal); + return { created: true, journal, session }; + } + try { + const session = await openComposedJournalSession( + input.journal_path, input.expected_authority, + ); + const journal = session.current(); + if (input.request !== undefined + && journal.request_digest !== createComposedRunRequestDigest(input.request)) { + throw new TypeError("durable composed request changed"); + } + return { created: false, journal, session }; + } catch (error) { + if (!missing(error)) throw error; + throw new TypeError("composed recovery journal is unavailable"); + } +}; + +/** Runs durably and converts signals/failures into one exact recovery receipt. */ +export const runDurableComposedRun = async ( + input: DurableComposedRunInput, +): Promise => { + const now = input.now ?? (() => new Date().toISOString()); + let pendingSignal: ComposedInterruptionSignal | undefined; + const controller = new AbortController(); + const interrupt = (signal: ComposedInterruptionSignal): void => { + pendingSignal ??= signal; + if (!controller.signal.aborted) controller.abort(new ComposedRunInterruption(signal)); + }; + const onSigint = (): void => { interrupt("SIGINT"); }; + const onSigterm = (): void => { interrupt("SIGTERM"); }; + process.on("SIGINT", onSigint); + process.on("SIGTERM", onSigterm); + let latest: ComposedPhaseJournal | undefined; + let session: ComposedJournalSession | undefined; + try { + const loaded = await loadOrCreate(input, now); + session = loaded.session; + latest = loaded.journal; + const boundary = async (phase: Parameters>[0]) => { + await input.fault_injector?.afterPhase?.(phase); + if (pendingSignal) throw new ComposedRunInterruption(pendingSignal); + }; + if (loaded.created) await boundary("requested"); + return await executeComposedRun({ + configuration: input.configuration, + context: { + fault_injector: { afterPhase: boundary }, + now, + persist: async (journal) => { + await loaded.session.replace(latest!, journal); + latest = journal; + }, + }, + journal: latest, + ports: input.ports, + signal: controller.signal, + }); + } catch (error) { + if (latest === undefined) throw error; + if (latest.current_phase === "completed") return completedComposedRunFromJournal(latest); + const signal = error instanceof ComposedRunInterruption + ? error.signal + : pendingSignal ?? "failure"; + const recoverable = markComposedJournalRecoverable(latest, { + recovery_command: composedRecoveryCommand( + input.journal_path, latest.request.run_id, latest.authority_digest, + ), + signal, + }); + if (session === undefined) throw error; + await session.replace(latest, recoverable); + latest = recoverable; + const interruption = recoverable.interruption; + if (interruption === null) throw new TypeError("composed recovery state is unavailable"); + return { + journal: recoverable, + receipt: createComposedRecoveryReceipt({ + authority_digest: recoverable.authority_digest, + journal_digest: recoverable.journal_digest, + journal_path: input.journal_path, + next_phase: interruption.next_phase, + preserved_evidence: composedPhaseReached(recoverable, "world_evidence_exported"), + run_id: recoverable.request.run_id, + signal, + }), + }; + } finally { + process.off("SIGINT", onSigint); + process.off("SIGTERM", onSigterm); + } +}; + +/** Implements the exact authority-bound `simfile recover` semantic operation. */ +export const recoverComposedRun = async ( + input: Omit & { + readonly expected_authority: ComposedJournalAuthorityExpectation; + }, +): Promise => runDurableComposedRun(input); + +export interface ComposedRecoveryArguments extends ComposedJournalAuthorityExpectation { + readonly journal_path: string; +} + +export const parseComposedRecoveryArguments = ( + argv: readonly string[], +): ComposedRecoveryArguments => { + const [journalFlag, journalPath, runFlag, runId, authorityFlag, authorityDigest, ...extra] = argv; + if (journalFlag !== "--journal" || journalPath === undefined + || runFlag !== "--run-id" || runId === undefined + || authorityFlag !== "--authority-digest" || authorityDigest === undefined + || extra.length > 0 || !path.isAbsolute(journalPath) || path.normalize(journalPath) !== journalPath + || journalPath === path.parse(journalPath).root + || !/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(runId) + || !/^sha256:[a-f0-9]{64}$/u.test(authorityDigest)) { + throw new TypeError("usage: simfile recover --journal --run-id --authority-digest "); + } + return { authority_digest: authorityDigest, journal_path: journalPath, run_id: runId }; +}; + +/** Thin command seam for the exact recovery command emitted by recovery receipts. */ +export const runComposedRecoveryCommand = async (input: Readonly<{ + argv: readonly string[]; + configuration: ComposedRunConfiguration; + fault_injector?: ComposedRunFaultInjector; + now?: () => string; + ports: ComposedRunPorts; +}>): Promise => { + const [command, ...args] = input.argv; + const parsed = parseComposedRecoveryArguments(command === "recover" ? args : []); + return recoverComposedRun({ + configuration: input.configuration, + expected_authority: { + authority_digest: parsed.authority_digest, + run_id: parsed.run_id, + }, + fault_injector: input.fault_injector, + journal_path: parsed.journal_path, + now: input.now, + ports: input.ports, + }); +}; diff --git a/src/compose/replay.test.ts b/src/compose/replay.test.ts new file mode 100644 index 0000000..4133da2 --- /dev/null +++ b/src/compose/replay.test.ts @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdtemp, readFile, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { stableStringify } from "../ledger/stable.js"; +import { COMPOSED_ARTIFACT_ROLES, createComposedRunRecord } from "./runRecord.js"; +import { replayComposedRunRecord } from "./replay.js"; + +const encode = (value: unknown): Uint8Array => new TextEncoder().encode( + typeof value === "string" ? value : `${stableStringify(value)}\n`, +); +const hash = (value: Uint8Array): string => createHash("sha256").update(value).digest("hex"); +const sealedRecord = async (options: Readonly<{ + accepted_action_count?: number; + actions?: Uint8Array; + terminal_value?: number; +}> = {}) => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-composed-replay-")); + const out = path.join(root, "run-replay"); + const checkpoint = encode({ value: 1 }); + const actions = options.actions + ?? encode('{"action":{"add":2},"boundary_tick":1,"ordinal":0}\n'); + const terminalValue = options.terminal_value ?? 3; + const terminal = encode({ value: terminalValue }); + const probe = encode({ passed: terminalValue === 3 }); + const expected = encode({ + accepted_action_count: options.accepted_action_count ?? 1, + action_stream_sha256: hash(actions), + initial_checkpoint_sha256: hash(checkpoint), probe_sha256: hash(probe), + terminal_state_sha256: hash(terminal), terminal_tick: 2, + version: "simfile.composed-replay-expectation.v1", + }); + const record = await createComposedRunRecord({ identity: { + contract_versions: {}, created_at: "2026-08-07T12:00:00.000Z", run_id: "run-replay", + }, out_dir: out }); + for (const [index, role] of COMPOSED_ARTIFACT_ROLES.entries()) { + await record.writeArtifact({ bytes: encode(`${role}-${index}\n`), + path: `required/${role}.json`, role }); + } + await record.writeArtifact({ bytes: checkpoint, path: "replay/initial-checkpoint.json", + role: "world-checkpoint" }); + await record.writeArtifact({ bytes: actions, path: "replay/accepted-actions.jsonl", + role: "accepted-action" }); + await record.writeArtifact({ bytes: expected, path: "replay/expected.json", role: "terminal" }); + await record.seal(); + return { actions, out, probe, terminal }; +}; +const adapter = () => ({ + restore: (raw: unknown) => ({ value: (raw as { value: number }).value }), + inject: ({ action, boundary_tick, state }: { action: unknown; boundary_tick: number; + state: { value: number } }) => { + if (boundary_tick !== 1) throw new Error("wrong boundary"); + state.value += (action as { add: number }).add; + }, + finish: (state: { value: number }) => ({ + probe: encode({ passed: state.value === 3 }), terminal_state: encode(state), terminal_tick: 2, + }), +}); + +describe("offline composed replay", () => { + it("restores one checkpoint and injects only the recorded accepted stream", async () => { + const fixture = await sealedRecord(); + const receipt = await replayComposedRunRecord({ adapter: adapter(), run_dir: fixture.out }); + assert.equal(receipt.exact, true); + assert.equal(receipt.accepted_action_count, 1); + assert.equal(receipt.terminal_tick, 2); + }); + + it("accepts the canonical empty stream when no actions were recorded", async () => { + const fixture = await sealedRecord({ + accepted_action_count: 0, actions: new Uint8Array(), terminal_value: 1, + }); + const receipt = await replayComposedRunRecord({ adapter: adapter(), run_dir: fixture.out }); + assert.equal(receipt.exact, true); + assert.equal(receipt.accepted_action_count, 0); + assert.equal(receipt.terminal_tick, 2); + }); + + it("fails on tamper, missing input, and a wrong recorded boundary", async () => { + const tampered = await sealedRecord(); + await writeFile(path.join(tampered.out, "replay/accepted-actions.jsonl"), "tamper\n"); + await assert.rejects(replayComposedRunRecord({ adapter: adapter(), run_dir: tampered.out }), + /artifact mismatch/u); + + const wrong = await sealedRecord(); + const manifestPath = path.join(wrong.out, "manifest.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")) as { + artifacts: Array<{ path: string; sha256: string }> }; + const actionPath = path.join(wrong.out, "replay/accepted-actions.jsonl"); + const changed = encode('{"action":{"add":2},"boundary_tick":2,"ordinal":0}\n'); + await writeFile(actionPath, changed); + manifest.artifacts.find(({ path: artifactPath }) => + artifactPath === "replay/accepted-actions.jsonl")!.sha256 = hash(changed); + await writeFile(manifestPath, `${stableStringify(manifest)}\n`); + await assert.rejects(replayComposedRunRecord({ adapter: adapter(), run_dir: wrong.out }), + /input correlation|wrong boundary/u); + }); + + it("has no service, provider, or cognition process imports", async () => { + const source = await readFile(new URL("./replay.ts", import.meta.url), "utf8"); + for (const forbidden of ["child_process", "spawnfile", "moltnet", "daimon", + "fetch(", "createServer", "executeComposedRun", "model"]) { + assert.equal(source.toLowerCase().includes(forbidden.toLowerCase()), false, forbidden); + } + }); +}); diff --git a/src/compose/replay.ts b/src/compose/replay.ts new file mode 100644 index 0000000..bfba128 --- /dev/null +++ b/src/compose/replay.ts @@ -0,0 +1,139 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import { verifyManifestArtifacts } from "../observe/artifacts.js"; +import { parseRunManifest } from "../observe/manifest.js"; +import { assertSecretFreeComposedJson } from "./json.js"; + +export const COMPOSED_REPLAY_EXPECTATION_VERSION = + "simfile.composed-replay-expectation.v1" as const; +export interface ComposedRecordedAction { + readonly action: unknown; + readonly boundary_tick: number; + readonly ordinal: number; +} +export interface ComposedReplayAdapter { + restore(initialCheckpoint: unknown): State | Promise; + inject(input: Readonly<{ action: unknown; boundary_tick: number; ordinal: number; + state: State }>): void | Promise; + finish(state: State): Readonly<{ probe: Uint8Array; terminal_state: Uint8Array; + terminal_tick: number }> | Promise>; +} +export interface ComposedReplayReceipt { + readonly accepted_action_count: number; + readonly exact: true; + readonly probe_sha256: string; + readonly run_id: string; + readonly terminal_state_sha256: string; + readonly terminal_tick: number; + readonly version: "simfile.composed-replay-receipt.v1"; +} + +const digest = (bytes: Uint8Array): string => + createHash("sha256").update(bytes).digest("hex"); +const exactObject = (raw: unknown, keys: readonly string[], label: string): Record => { + assertSecretFreeComposedJson(raw); + if (raw === null || typeof raw !== "object" || Array.isArray(raw) + || Object.keys(raw).sort().join("\0") !== [...keys].sort().join("\0")) { + throw new TypeError(`composed replay ${label} is invalid`); + } + return raw as Record; +}; +const parseAction = (raw: unknown, expectedOrdinal: number): ComposedRecordedAction => { + const value = exactObject(raw, ["action", "boundary_tick", "ordinal"], "action"); + if (!Number.isSafeInteger(value.boundary_tick) || (value.boundary_tick as number) < 0 + || value.ordinal !== expectedOrdinal) { + throw new TypeError("composed replay action boundary is invalid"); + } + return Object.freeze({ action: value.action, boundary_tick: value.boundary_tick as number, + ordinal: expectedOrdinal }); +}; +const parseActions = (bytes: Uint8Array): readonly ComposedRecordedAction[] => { + const text = new TextDecoder("utf8", { fatal: true }).decode(bytes); + if (text.length === 0) return Object.freeze([]); + if (!text.endsWith("\n")) throw new TypeError("composed replay action stream is truncated"); + const lines = text.split("\n").slice(0, -1); + const actions = lines.map((line, ordinal) => parseAction(JSON.parse(line) as unknown, ordinal)); + for (let index = 1; index < actions.length; index += 1) { + if (actions[index]!.boundary_tick < actions[index - 1]!.boundary_tick) { + throw new TypeError("composed replay action boundary regressed"); + } + } + return Object.freeze(actions); +}; + +interface ReplayExpectation { + readonly accepted_action_count: number; + readonly action_stream_sha256: string; + readonly initial_checkpoint_sha256: string; + readonly probe_sha256: string; + readonly terminal_state_sha256: string; + readonly terminal_tick: number; +} +const parseExpectation = (raw: unknown): ReplayExpectation => { + const value = exactObject(raw, [ + "accepted_action_count", "action_stream_sha256", "initial_checkpoint_sha256", + "probe_sha256", "terminal_state_sha256", "terminal_tick", "version", + ], "expectation"); + const hashes = ["action_stream_sha256", "initial_checkpoint_sha256", + "probe_sha256", "terminal_state_sha256"] as const; + if (value.version !== COMPOSED_REPLAY_EXPECTATION_VERSION + || !Number.isSafeInteger(value.accepted_action_count) + || (value.accepted_action_count as number) < 0 + || !Number.isSafeInteger(value.terminal_tick) || (value.terminal_tick as number) < 1 + || hashes.some((key) => typeof value[key] !== "string" + || !/^[a-f0-9]{64}$/u.test(value[key] as string))) { + throw new TypeError("composed replay expectation is invalid"); + } + return value as unknown as ReplayExpectation; +}; + +/** Replays a sealed record through an injected mechanics-only adapter. */ +export const replayComposedRunRecord = async (input: Readonly<{ + adapter: ComposedReplayAdapter; + run_dir: string; +}>): Promise => { + const runDir = path.resolve(input.run_dir); + const manifest = parseRunManifest(JSON.parse( + await readFile(path.join(runDir, "manifest.json"), "utf8"), + ) as unknown); + const integrity = await verifyManifestArtifacts(runDir, manifest.artifacts); + const failed = integrity.find(({ ok }) => !ok); + if (failed !== undefined) throw new TypeError(`composed replay artifact mismatch: ${failed.path}`); + const required = ["replay/initial-checkpoint.json", "replay/accepted-actions.jsonl", + "replay/expected.json"] as const; + const declared = new Set(manifest.artifacts.map(({ path: artifactPath }) => artifactPath)); + if (required.some((artifactPath) => !declared.has(artifactPath))) { + throw new TypeError("composed replay artifacts are incomplete"); + } + const [checkpointBytes, actionBytes, expectationBytes] = await Promise.all( + required.map((relative) => readFile(path.join(runDir, relative))), + ); + const expectation = parseExpectation(JSON.parse(expectationBytes.toString("utf8")) as unknown); + const actions = parseActions(actionBytes); + if (digest(checkpointBytes) !== expectation.initial_checkpoint_sha256 + || digest(actionBytes) !== expectation.action_stream_sha256 + || actions.length !== expectation.accepted_action_count + || actions.some(({ boundary_tick }) => boundary_tick >= expectation.terminal_tick)) { + throw new TypeError("composed replay input correlation is invalid"); + } + const checkpoint = JSON.parse(checkpointBytes.toString("utf8")) as unknown; + assertSecretFreeComposedJson(checkpoint); + const state = await input.adapter.restore(checkpoint); + for (const action of actions) await input.adapter.inject({ ...action, state }); + const result = await input.adapter.finish(state); + const terminalStateDigest = digest(result.terminal_state); + const probeDigest = digest(result.probe); + if (result.terminal_tick !== expectation.terminal_tick + || terminalStateDigest !== expectation.terminal_state_sha256 + || probeDigest !== expectation.probe_sha256) { + throw new TypeError("composed replay exact comparison failed"); + } + return Object.freeze({ + accepted_action_count: actions.length, exact: true, probe_sha256: probeDigest, + run_id: manifest.run_id, terminal_state_sha256: terminalStateDigest, + terminal_tick: result.terminal_tick, version: "simfile.composed-replay-receipt.v1", + }); +}; diff --git a/src/compose/request-receipt.test.ts b/src/compose/request-receipt.test.ts new file mode 100644 index 0000000..9af03bb --- /dev/null +++ b/src/compose/request-receipt.test.ts @@ -0,0 +1,135 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + COMPOSED_RECOVERY_RECEIPT_VERSION, + composedRecoveryCommand, + createComposedRecoveryReceipt, + createComposedTerminalReceipt, + parseComposedRecoveryReceipt, + parseComposedTerminalReceipt, + verifyComposedTerminalReceipt, +} from "./receipt.js"; +import { digestComposedJson } from "./json.js"; +import { + createComposedRunRequestDigest, + parseComposedRunRequest, + WORLD_DECISION_CLAIM_CAPABILITY, +} from "./request.js"; + +const sha = (value: string): `sha256:${string}` => + `sha256:${value.repeat(64).slice(0, 64)}`; +const request = parseComposedRunRequest({ + descriptor_digest: sha("a"), + mode: "dry-run", + organization: { + artifact_digest: sha("b"), + source_digest: sha("c"), + world_bindings_digest: sha("d"), + }, + required_world_capabilities: [], + run_id: "run-one", + source_digest: sha("e"), + target: { auth_profile: "simfile-live", selector: "gpu-4090" }, + version: "simfile.composed-run-request.v1", + world: { + artifact_manifest_digest: sha("f"), + bundle_digest: sha("1"), + runtime_abi: "simfile.world-sidecar-runtime.v1", + }, +}); + +const terminal = () => createComposedTerminalReceipt({ + cleanup: { receipt_digest: sha("2"), remaining_owned_resources: [], state: "cleaned" }, + evidence: { + organization: { authority: "organization", digest: sha("3"), item_count: 4, state: "exported" }, + world: { authority: "world", digest: sha("4"), item_count: 5, state: "exported" }, + }, + journal_digest: sha("5"), + request, + seal: { digest: sha("6"), state: "sealed" }, + target: { + preparation_receipt_digest: sha("7"), + selected_target: { + fingerprint: `sha256:${"8".repeat(32)}`, + handle: `opaque_${"9".repeat(16)}`, + }, + selector: "gpu-4090", + }, + topology: { + activation_receipt_digest: sha("a"), + receipt_digest: sha("b"), + request_digest: sha("c"), + }, + verdict: { digest: sha("d"), state: "valid" }, +}); + +test("composed request and terminal receipt bind all public identities", () => { + const receipt = terminal(); + assert.equal(receipt.run_id, request.run_id); + assert.equal(receipt.request_digest, createComposedRunRequestDigest(request)); + assert.deepEqual(parseComposedTerminalReceipt(receipt), receipt); + assert.deepEqual(verifyComposedTerminalReceipt(receipt, request, sha("5")), receipt); +}); + +test("live request requires the declared Phase 3 decision-claim capability hook", () => { + assert.throws(() => parseComposedRunRequest({ ...request, mode: "live" }), /decision-claim/u); + assert.doesNotThrow(() => parseComposedRunRequest({ + ...request, + mode: "live", + required_world_capabilities: [WORLD_DECISION_CLAIM_CAPABILITY], + })); +}); + +test("terminal receipt rejects tamper, cross-run, unclean completion, and secret shapes", () => { + const receipt = terminal(); + assert.throws(() => parseComposedTerminalReceipt({ + ...receipt, + evidence: { ...receipt.evidence, world: { ...receipt.evidence.world, item_count: 6 } }, + }), /digest/u); + assert.throws(() => verifyComposedTerminalReceipt(receipt, { + ...request, + run_id: "run-foreign", + }), /correlation/u); + assert.throws(() => createComposedTerminalReceipt({ + cleanup: { + receipt_digest: sha("2"), + remaining_owned_resources: [`opaque_${"e".repeat(16)}`], + state: "cleaned", + }, + evidence: receipt.evidence, + journal_digest: sha("5"), + request, + seal: receipt.seal, + target: receipt.target, + topology: receipt.topology, + verdict: receipt.verdict, + }), /retains resources/u); + assert.throws(() => parseComposedTerminalReceipt({ ...receipt, token: "sk-secretsecretsecret" }), /secret-shaped/u); +}); + +test("recovery receipt emits one exact idempotent command and rejects forgery", () => { + const receipt = createComposedRecoveryReceipt({ + authority_digest: sha("d"), + journal_digest: sha("e"), + journal_path: "/tmp/run one's/journal.json", + next_phase: "world_created", + preserved_evidence: true, + run_id: "run-one", + signal: "SIGINT", + }); + assert.equal(receipt.recovery_command, + `simfile recover --journal '/tmp/run one'"'"'s/journal.json' --run-id 'run-one' --authority-digest '${sha("d")}'`); + assert.deepEqual(parseComposedRecoveryReceipt(receipt), receipt); + for (const recovery_command of [ + composedRecoveryCommand("/tmp/foreign.json", receipt.run_id, receipt.authority_digest), + composedRecoveryCommand(receipt.journal_path, "run-foreign", receipt.authority_digest), + composedRecoveryCommand(receipt.journal_path, receipt.run_id, sha("f")), + ]) { + const { receipt_digest: _digest, ...original } = receipt; + const body = { ...original, recovery_command }; + assert.throws(() => parseComposedRecoveryReceipt({ ...body, + receipt_digest: digestComposedJson(COMPOSED_RECOVERY_RECEIPT_VERSION, body), + }), /command/u); + } +}); diff --git a/src/compose/request.ts b/src/compose/request.ts new file mode 100644 index 0000000..4617c57 --- /dev/null +++ b/src/compose/request.ts @@ -0,0 +1,53 @@ +import { z } from "zod"; + +import { assertSecretFreeComposedJson, digestComposedJson } from "./json.js"; + +export const COMPOSED_RUN_REQUEST_VERSION = "simfile.composed-run-request.v1" as const; +export const WORLD_DECISION_CLAIM_CAPABILITY = "simfile.world-decision-claim.v1" as const; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const identifier = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); +const runId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); +const capability = z.string().regex(/^[a-z][a-z0-9.-]{0,127}\.v[1-9][0-9]*$/u); + +export const composedRunRequestSchema = z.object({ + descriptor_digest: digest, + mode: z.enum(["dry-run", "live"]), + organization: z.object({ + artifact_digest: digest, + source_digest: digest, + world_bindings_digest: digest, + }).strict(), + required_world_capabilities: z.array(capability).max(32), + run_id: runId, + source_digest: digest, + target: z.object({ + auth_profile: identifier, + selector: identifier, + }).strict(), + version: z.literal(COMPOSED_RUN_REQUEST_VERSION), + world: z.object({ + artifact_manifest_digest: digest, + bundle_digest: digest, + runtime_abi: z.literal("simfile.world-sidecar-runtime.v1"), + }).strict(), +}).strict().superRefine((value, context) => { + const capabilities = value.required_world_capabilities; + if (new Set(capabilities).size !== capabilities.length + || capabilities.some((item, index) => index > 0 && capabilities[index - 1]! >= item)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "world capabilities must be sorted and unique" }); + } + if (value.mode === "live" && !capabilities.includes(WORLD_DECISION_CLAIM_CAPABILITY)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "live mode requires decision-claim capability" }); + } +}); + +export type ComposedRunRequest = z.infer; + +export const parseComposedRunRequest = (raw: unknown): ComposedRunRequest => { + assertSecretFreeComposedJson(raw); + return Object.freeze(composedRunRequestSchema.parse(raw)); +}; + +export const createComposedRunRequestDigest = (raw: unknown): `sha256:${string}` => + digestComposedJson(COMPOSED_RUN_REQUEST_VERSION, parseComposedRunRequest(raw)); diff --git a/src/compose/run.test-helper.ts b/src/compose/run.test-helper.ts new file mode 100644 index 0000000..e489507 --- /dev/null +++ b/src/compose/run.test-helper.ts @@ -0,0 +1,302 @@ +import { + createComposedTopologyActivationReceipt, + createComposedTopologyAttestationReceipt, + createComposedWorldTickReceipt, +} from "./activation.js"; +import { createComposedCleanupOperationReceipt } from "./cleanup.js"; +import { createComposedWorldEvidenceReceipt, createComposedWorldPauseReceipt } from "./finalize-world.js"; +import { digestComposedJson } from "./json.js"; +import { + lifecycleDigest, + lifecycleHandle, + lifecycleOrganizationExpectation, + lifecycleOrganizationUpReceipt, + lifecyclePreparation, + lifecycleReadiness, + lifecycleReadinessExpectation, +} from "./lifecycle.test-helper.js"; +import type { ComposedRunRequest } from "./request.js"; +import type { ComposedRunConfiguration, ComposedRunPorts } from "./run.js"; +import { + createComposedWorldResourceReceipt, + createComposedWorldServiceReceipt, +} from "./startup-world.js"; +import { createComposedWorldTerminalReceipt } from "./supervision.js"; + +export type ComposedHarnessMutation = Readonly<{ + activation?: (value: unknown) => unknown; + cleanup?: (value: unknown) => unknown; + organization_evidence?: (value: unknown) => unknown; + preparation?: (value: unknown) => unknown; + terminal?: (value: unknown) => unknown; + world_evidence?: (value: unknown) => unknown; +}>; + +export interface ComposedHarnessTelemetry { + readonly calls: string[]; + readonly cleanup_targets: string[]; + readonly effect_counts: Record; + activation_publications: number; + participant_actions: number; +} + +const organizationExport = (request: ComposedRunRequest) => ({ + deployment: "organization-unit", + failed_files: [], + index: { + deployment: "organization-unit", + exported_at: "2026-01-01T00:00:14.000Z", + files: [ + { bytes: 1, path: "raw/daimon/member/log.jsonl", sha256: "a".repeat(64), source: { kind: "volume", ref: "d:/log" } }, + { bytes: 1, path: "raw/mneme/bank/log.jsonl", sha256: "b".repeat(64), source: { kind: "volume", ref: "m:/log" } }, + { bytes: 1, path: "raw/moltnet/log.jsonl", sha256: "c".repeat(64), source: { kind: "volume", ref: "n:/log" } }, + ], + run_id: request.run_id, + version: "spawnfile.export-index.v1", + }, + index_path: "/evidence/spawnfile/export-index.json", + missing_optional_files: [], +}); + +const mutate = (operation: ((value: unknown) => unknown) | undefined, value: unknown): unknown => + operation?.(value) ?? value; + +/** Builds one deterministic, zero-agent, isolated target for composed-run integration tests. */ +export const createComposedRunHarness = ( + request: ComposedRunRequest, + mutation: ComposedHarnessMutation = {}, +): Readonly<{ + configuration: ComposedRunConfiguration; + ports: ComposedRunPorts; + telemetry: ComposedHarnessTelemetry; +}> => { + const organizationExpectation = lifecycleOrganizationExpectation(); + const topologyExpectation = { + selected_target: { + fingerprint: `sha256:${"1".repeat(32)}`, + handle: lifecycleHandle("6"), + }, + topology_request_digest: lifecycleDigest("7"), + }; + const configuration: ComposedRunConfiguration = { + deployment_name: "organization-unit", + organization_expectation: organizationExpectation, + readiness_expectation: lifecycleReadinessExpectation(request), + terminal_tick: 4, + topology_expectation: topologyExpectation, + }; + const telemetry: ComposedHarnessTelemetry = { + activation_publications: 0, + calls: [], + cleanup_targets: [], + effect_counts: {}, + participant_actions: 0, + }; + const effects = new Set(); + const effect = (name: string, key: string): void => { + const identity = `${name}:${key}`; + if (effects.has(identity)) return; + effects.add(identity); + telemetry.effect_counts[name] = (telemetry.effect_counts[name] ?? 0) + 1; + }; + let activated = false; + let worldPhaseDigest: string | undefined; + const preparation = lifecyclePreparation(request); + const resource = createComposedWorldResourceReceipt({ + artifact_digest: request.world.artifact_manifest_digest, + bundle_digest: request.world.bundle_digest, + preparation_receipt_digest: preparation.receipt_digest, + resource_handle: lifecycleHandle("2"), + run_id: request.run_id, + }); + const service = createComposedWorldServiceReceipt({ + resource_handle: resource.resource_handle, + run_id: request.run_id, + service_handle: lifecycleHandle("3"), + }); + const ports: ComposedRunPorts = { + cleanup: { + performCleanupOperation: async (input) => { + telemetry.calls.push(`cleanup:${input.operation}`); + telemetry.cleanup_targets.push(...input.target_handles); + effect(`cleanup:${input.operation}`, input.idempotency_key); + const released = input.operation === "stop_world" ? [] : [...input.target_handles]; + const receipt = createComposedCleanupOperationReceipt({ + operation: input.operation, + ownership_digest: input.ownership_digest, + released_handles: released.sort(), + remaining_owned_handles: input.owned_handles + .filter((handle) => !released.includes(handle)).sort(), + run_id: input.run_id, + state: "completed", + target_handles: [...input.target_handles].sort(), + }); + return mutate(mutation.cleanup, receipt); + }, + }, + organization: { + readOrganizationReadiness: async () => { + telemetry.calls.push("organization:ready"); + return lifecycleOrganizationUpReceipt(request.run_id, true); + }, + startOrganization: async (input) => { + telemetry.calls.push("organization:start"); + effect("organization:start", input.idempotency_key); + return lifecycleOrganizationUpReceipt(request.run_id, false); + }, + }, + organization_finalization: { + exportOrganizationEvidence: async (input) => { + telemetry.calls.push("organization:export"); + effect("organization:export", input.lifecycle_invocation_id); + return mutate(mutation.organization_evidence, organizationExport(request)); + }, + }, + preparation: { + prepareComposedRun: async (input) => { + telemetry.calls.push("target:prepare"); + effect("target:prepare", input.idempotency_key); + return mutate(mutation.preparation, preparation); + }, + }, + supervision: { + waitForWorldTerminal: async (input) => { + telemetry.calls.push("world:terminal"); + return mutate(mutation.terminal, createComposedWorldTerminalReceipt({ + outcome_digest: lifecycleDigest("0"), + reason: "completed", + run_id: request.run_id, + running_receipt_digest: input.running.receipt_digest, + terminal_tick: input.expected_terminal_tick, + })); + }, + }, + topology: { + activateTopology: async (input) => { + telemetry.calls.push("topology:activate"); + effect("topology:activate", input.idempotency_key); + activated = true; + telemetry.activation_publications = telemetry.effect_counts["topology:activate"] ?? 0; + const topology = input.attestation.target_topology; + const marker = { + bundle_digest: request.world.artifact_manifest_digest, + run_id: request.run_id, + state: "activated" as const, + topology_receipt_digest: topology.receipt_digest, + topology_request_digest: topology.request_digest, + version: "spawnfile.world-service-activation.v1" as const, + }; + const body = { + activation_digest: digestComposedJson("spawnfile.world-service-activation.v1", marker), + bundle_digest: marker.bundle_digest, + run_id: marker.run_id, + state: marker.state, + topology_receipt_digest: marker.topology_receipt_digest, + topology_request_digest: marker.topology_request_digest, + version: "spawnfile.target-topology-activation-receipt.v1" as const, + }; + const receipt = createComposedTopologyActivationReceipt({ + attestation_receipt_digest: input.attestation.receipt_digest, + run_id: request.run_id, + target_activation: { + ...body, + receipt_digest: digestComposedJson( + "spawnfile.target-topology-activation-receipt.v1", body, + ), + }, + }); + return mutate(mutation.activation, receipt); + }, + attestTopology: async (input) => { + telemetry.calls.push("topology:attest"); + worldPhaseDigest = input.world_phase_digest; + const body = { + descriptor_digest: request.descriptor_digest, + handoff_scope: "organization_to_private_service" as const, + organization: { + data_network_attachment: "exact" as const, + egress_policy: "egress_only" as const, + }, + request_digest: input.topology_request_digest, + run_id: request.run_id, + selected_target: topologyExpectation.selected_target, + service_discovery: "dns_only" as const, + version: "spawnfile.target-topology-receipt.v1" as const, + world_network: "private_internal" as const, + world_service: { + data_network_attachment: "exactly_one" as const, + egress_policy: "none" as const, + published_ports: "none" as const, + }, + }; + return createComposedTopologyAttestationReceipt({ + organization_phase_digest: input.organization_phase_digest, + request_digest: input.request_digest, + run_id: input.run_id, + target_topology: { + ...body, + receipt_digest: digestComposedJson("spawnfile.target-topology-receipt.v1", body), + }, + world_phase_digest: input.world_phase_digest, + }); + }, + readFirstTick: async (input) => { + telemetry.calls.push("world:tick-1"); + if (!activated || worldPhaseDigest === undefined) { + throw new Error("clock is paused before activation"); + } + return createComposedWorldTickReceipt({ + activation_receipt_digest: input.activation.receipt_digest, + clock: { completed_tick: 1, next_tick: 2, state: "running" }, + run_id: request.run_id, + world_phase_digest: worldPhaseDigest, + }); + }, + }, + world: { + createWorldResource: async (input) => { + telemetry.calls.push("world:create"); + effect("world:create", input.idempotency_key); + return resource; + }, + readWorldReadiness: async () => { + telemetry.calls.push("world:ready"); + return lifecycleReadiness(request); + }, + startWorldPaused: async (input) => { + telemetry.calls.push("world:start-paused"); + effect("world:start-paused", input.idempotency_key); + return service; + }, + }, + world_finalization: { + exportWorldEvidence: async (input) => { + telemetry.calls.push("world:export"); + effect("world:export", input.idempotency_key); + const receipt = createComposedWorldEvidenceReceipt({ + export_handle: lifecycleHandle("7"), + inventory: [ + { authority: "actions", bytes: 1, path: "actions/log.jsonl", sha256: lifecycleDigest("a") }, + { authority: "checkpoints", bytes: 2, path: "checkpoints/final.json", sha256: lifecycleDigest("b") }, + { authority: "projections", bytes: 3, path: "projections/world.json", sha256: lifecycleDigest("c") }, + ], + pause_receipt_digest: input.pause.receipt_digest, + run_id: request.run_id, + source_service_handle: service.service_handle, + }); + return mutate(mutation.world_evidence, receipt); + }, + pauseWorld: async (input) => { + telemetry.calls.push("world:pause"); + effect("world:pause", input.idempotency_key); + return createComposedWorldPauseReceipt({ + final_tick: input.terminal.terminal_tick, + run_id: request.run_id, + service_handle: service.service_handle, + terminal_receipt_digest: input.terminal.receipt_digest, + }); + }, + }, + }; + return { configuration, ports, telemetry }; +}; diff --git a/src/compose/run.test.ts b/src/compose/run.test.ts new file mode 100644 index 0000000..e49ede1 --- /dev/null +++ b/src/compose/run.test.ts @@ -0,0 +1,235 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { + createComposedTopologyActivationReceipt, + parseComposedTopologyActivationReceipt, +} from "./activation.js"; +import { + createComposedCleanupOperationReceipt, + parseComposedCleanupOperationReceipt, +} from "./cleanup.js"; +import { digestComposedJson } from "./json.js"; +import { readComposedPhaseJournal } from "./journal.js"; +import { + lifecycleDigest, + lifecycleHandle, + lifecyclePreparation, + lifecycleRequest, +} from "./lifecycle.test-helper.js"; +import { recoverComposedRun, runDurableComposedRun } from "./recovery.js"; +import { createComposedRunHarness, type ComposedHarnessMutation } from "./run.test-helper.js"; +import { createComposedWorldTerminalReceipt } from "./supervision.js"; +import { COMPOSED_RUN_PHASES, composedRunPhaseIndex } from "./types.js"; + +const clock = () => { + let tick = 0; + return () => new Date(Date.UTC(2026, 1, 1, 0, 0, tick++)).toISOString(); +}; + +test("one dry-run composes every owner with zero participant actions", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "simfile-composed-e2e-")); + try { + const request = lifecycleRequest(); + const harness = createComposedRunHarness(request); + const journalPath = path.join(directory, "journal.json"); + const outcome = await runDurableComposedRun({ + configuration: harness.configuration, + journal_path: journalPath, + now: clock(), + ports: harness.ports, + request, + }); + assert.equal(outcome.receipt.status, "completed"); + assert.equal(outcome.journal.current_phase, "completed"); + assert.deepEqual(outcome.journal.entries.map((entry) => entry.phase), COMPOSED_RUN_PHASES); + assert.equal(outcome.receipt.run_id, request.run_id); + assert.equal(outcome.receipt.verdict.state, "valid"); + assert.deepEqual(outcome.receipt.cleanup.remaining_owned_resources, []); + assert.equal(harness.telemetry.participant_actions, 0); + assert.equal(harness.telemetry.activation_publications, 1); + assert.deepEqual(harness.telemetry.calls, [ + "target:prepare", + "world:create", "world:start-paused", "world:ready", + "organization:start", "organization:ready", + "topology:attest", "topology:activate", "world:tick-1", "world:terminal", + "world:pause", "world:export", "organization:export", + "cleanup:stop_world", "cleanup:detach_organization", "cleanup:down_organization", + "cleanup:revoke_secret_bindings", "cleanup:cleanup_target_resources", + ]); + assert.equal(Object.values(harness.telemetry.effect_counts).every((count) => count === 1), true); + const callsBeforeReplay = harness.telemetry.calls.length; + const replay = await recoverComposedRun({ + configuration: harness.configuration, + expected_authority: { + authority_digest: outcome.journal.authority_digest, + run_id: outcome.journal.request.run_id, + }, + journal_path: journalPath, + now: clock(), + ports: harness.ports, + }); + assert.equal(replay.receipt.status, "completed"); + assert.equal(harness.telemetry.calls.length, callsBeforeReplay); + assert.deepEqual(await readComposedPhaseJournal(journalPath), replay.journal); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); + +const staleActivation = (raw: unknown): unknown => { + const receipt = parseComposedTopologyActivationReceipt(raw); + const marker = { + ...receipt.target_activation, + topology_request_digest: lifecycleDigest("9"), + version: "spawnfile.world-service-activation.v1" as const, + }; + const markerBody = { + bundle_digest: marker.bundle_digest, + run_id: marker.run_id, + state: marker.state, + topology_receipt_digest: marker.topology_receipt_digest, + topology_request_digest: marker.topology_request_digest, + version: marker.version, + }; + const targetBody = { + activation_digest: digestComposedJson("spawnfile.world-service-activation.v1", markerBody), + bundle_digest: marker.bundle_digest, + run_id: marker.run_id, + state: marker.state, + topology_receipt_digest: marker.topology_receipt_digest, + topology_request_digest: marker.topology_request_digest, + version: "spawnfile.target-topology-activation-receipt.v1" as const, + }; + return createComposedTopologyActivationReceipt({ + attestation_receipt_digest: receipt.attestation_receipt_digest, + run_id: receipt.run_id, + target_activation: { + ...targetBody, + receipt_digest: digestComposedJson( + "spawnfile.target-topology-activation-receipt.v1", targetBody, + ), + }, + }); +}; + +const foreignCleanup = (raw: unknown): unknown => { + const receipt = parseComposedCleanupOperationReceipt(raw); + const foreign = lifecycleHandle("z"); + return createComposedCleanupOperationReceipt({ + operation: receipt.operation, + ownership_digest: receipt.ownership_digest, + released_handles: [foreign], + remaining_owned_handles: receipt.remaining_owned_handles, + run_id: receipt.run_id, + state: "completed", + target_handles: [...receipt.target_handles, foreign].sort(), + }); +}; + +test("forgery sweep fails closed before unsafe downstream work", async () => { + const request = lifecycleRequest(); + const cases: ReadonlyArray> = [ + { + current_phase: "requested", + mutation: { + preparation: () => lifecyclePreparation(lifecycleRequest({ run_id: "run-cross" })), + }, + name: "cross-run preparation", + }, + { + current_phase: "topology_verified", + mutation: { activation: staleActivation }, + name: "stale activation", + }, + { + current_phase: "world_paused", + mutation: { + world_evidence: (raw) => ({ + ...(raw as Record), inventory_digest: lifecycleDigest("f"), + }), + }, + name: "tampered world evidence", + }, + { + current_phase: "world_evidence_exported", + mutation: { + organization_evidence: (raw) => ({ + ...(raw as Record), token: "must-not-enter-journal", + }), + }, + name: "secret-shaped organization evidence", + }, + { + current_phase: "organization_evidence_exported", + mutation: { cleanup: foreignCleanup }, + name: "foreign cleanup receipt", + }, + ]; + for (const forgery of cases) { + const directory = await mkdtemp(path.join(tmpdir(), "simfile-composed-forgery-")); + try { + const harness = createComposedRunHarness(request, forgery.mutation); + const outcome = await runDurableComposedRun({ + configuration: harness.configuration, + journal_path: path.join(directory, "journal.json"), + now: clock(), + ports: harness.ports, + request, + }); + assert.equal(outcome.receipt.status, "recovery_required", forgery.name); + assert.equal(outcome.journal.current_phase, forgery.current_phase, forgery.name); + assert.equal( + harness.telemetry.cleanup_targets.includes(lifecycleHandle("z")), false, forgery.name, + ); + if (composedRunPhaseIndex(forgery.current_phase) + < composedRunPhaseIndex("organization_evidence_exported")) { + assert.equal( + harness.telemetry.calls.some((call) => call.startsWith("cleanup:")), false, forgery.name, + ); + } + } finally { + await rm(directory, { force: true, recursive: true }); + } + } +}); + +test("an interrupted world terminal remains recoverable and never reaches export or cleanup", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "simfile-composed-terminal-")); + try { + const request = lifecycleRequest({ run_id: "run-terminal-interrupted" }); + const harness = createComposedRunHarness(request, { + terminal: (raw) => { + const terminal = raw as Record; + const body = { + outcome_digest: terminal.outcome_digest as string, + reason: "interrupted" as const, + run_id: terminal.run_id as string, + running_receipt_digest: terminal.running_receipt_digest as string, + terminal_tick: terminal.terminal_tick as number, + }; + return createComposedWorldTerminalReceipt(body); + }, + }); + const outcome = await runDurableComposedRun({ + configuration: harness.configuration, + journal_path: path.join(directory, "journal.json"), + now: clock(), + ports: harness.ports, + request, + }); + assert.equal(outcome.receipt.status, "recovery_required"); + assert.equal(outcome.journal.current_phase, "running"); + assert.equal(harness.telemetry.calls.some((call) => + call.includes("export") || call.startsWith("cleanup:")), false); + } finally { + await rm(directory, { force: true, recursive: true }); + } +}); diff --git a/src/compose/run.ts b/src/compose/run.ts new file mode 100644 index 0000000..29a06e8 --- /dev/null +++ b/src/compose/run.ts @@ -0,0 +1,304 @@ +import { + parseSpawnfileComposedPreparationReceipt, + type SpawnfileComposedPreparationReceipt, +} from "../spawnfile/preparationReceipt.js"; +import { + activateComposedTopology, + parseComposedTopologyActivationReceipt, + parseComposedTopologyAttestationReceipt, + type ComposedTopologyActivationPort, + type ComposedTopologyExpectation, +} from "./activation.js"; +import { cleanupComposedRun, parseComposedCleanupReceipt, type ComposedCleanupPort } from "./cleanup.js"; +import { + finalizeComposedOrganization, + parseComposedOrganizationEvidenceReceipt, + type ComposedOrganizationFinalizationPort, +} from "./finalize-organization.js"; +import { + finalizeComposedWorld, + parseComposedWorldPauseReceipt, + parseComposedWorldEvidenceReceipt, + type ComposedWorldFinalizationPort, +} from "./finalize-world.js"; +import { digestComposedJson } from "./json.js"; +import { parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; +import { + commitComposedPhase, + composedPhasePayload, + composedPhaseReached, + type ComposedPhaseContext, +} from "./phase.js"; +import { + createComposedTerminalReceipt, + type ComposedTerminalReceipt, + verifyComposedTerminalReceipt, +} from "./receipt.js"; +import type { ComposedRunRequest } from "./request.js"; +import { + startComposedOrganization, + type ComposedOrganizationExpectation, + type ComposedOrganizationStartupPort, +} from "./startup-organization.js"; +import { + startComposedWorld, + parseComposedWorldServiceReceipt, + type ComposedWorldStartupPort, +} from "./startup-world.js"; +import { + superviseComposedWorld, + type ComposedSupervisionPort, +} from "./supervision.js"; +import type { WorldSidecarReadinessExpectation } from "../world-artifact/readiness.js"; +import { composedRunPhaseIndex } from "./types.js"; + +export interface ComposedPreparationPort { + prepareComposedRun(input: Readonly<{ + idempotency_key: string; + request: ComposedRunRequest; + signal: AbortSignal; + }>): Promise; +} + +export interface ComposedRunPorts { + readonly cleanup: ComposedCleanupPort; + readonly organization: ComposedOrganizationStartupPort; + readonly organization_finalization: ComposedOrganizationFinalizationPort; + readonly preparation: ComposedPreparationPort; + readonly supervision: ComposedSupervisionPort; + readonly topology: ComposedTopologyActivationPort; + readonly world: ComposedWorldStartupPort; + readonly world_finalization: ComposedWorldFinalizationPort; +} + +export interface ComposedRunConfiguration { + readonly deployment_name: string; + readonly operator_timeout_ms?: number; + readonly organization_expectation: ComposedOrganizationExpectation; + readonly readiness_expectation: WorldSidecarReadinessExpectation; + readonly terminal_tick: number; + readonly topology_expectation: ComposedTopologyExpectation; +} + +export interface CompletedComposedRun { + readonly journal: ComposedPhaseJournal; + readonly receipt: ComposedTerminalReceipt; +} + +const terminalJournalDigest = (journal: ComposedPhaseJournal): string => + digestComposedJson("simfile.composed-terminal-journal.v1", { + entries: journal.entries.slice(0, composedRunPhaseIndex("completed")), + request_digest: journal.request_digest, + }); + +export const completedComposedRunFromJournal = (raw: unknown): CompletedComposedRun => { + const journal = parseComposedPhaseJournal(raw); + if (journal.current_phase !== "completed") { + throw new TypeError("composed journal is not complete"); + } + return { + journal, + receipt: verifyComposedTerminalReceipt( + composedPhasePayload(journal, "completed").receipt, + journal.request, + terminalJournalDigest(journal), + ), + }; +}; + +const preparationKey = (journal: ComposedPhaseJournal): string => + `idem_${digestComposedJson("simfile.composed-preparation-operation.v1", { + operation: "prepare_composed_run", request_digest: journal.request_digest, + }).slice(7, 39)}`; + +const verifyPreparation = ( + raw: unknown, + journal: ComposedPhaseJournal, + topology: ComposedTopologyExpectation, +): SpawnfileComposedPreparationReceipt => { + const receipt = parseSpawnfileComposedPreparationReceipt(raw); + if (receipt.run_id !== journal.request.run_id + || receipt.descriptor_digest !== journal.request.descriptor_digest + || receipt.auth_profile !== journal.request.target.auth_profile + || receipt.target_selector !== journal.request.target.selector + || receipt.organization.artifact_digest !== journal.request.organization.artifact_digest + || receipt.organization.world_bindings_digest + !== journal.request.organization.world_bindings_digest + || receipt.world.artifact_manifest_digest !== journal.request.world.artifact_manifest_digest + || receipt.world.bundle_digest !== journal.request.world.bundle_digest + || receipt.selected_target.fingerprint !== topology.selected_target.fingerprint + || receipt.selected_target.handle !== topology.selected_target.handle) { + throw new TypeError("composed preparation correlation is invalid"); + } + return receipt; +}; + +const completeReceipt = ( + journal: ComposedPhaseJournal, + preparation: SpawnfileComposedPreparationReceipt, + configuration: ComposedRunConfiguration, +): ComposedTerminalReceipt => { + const cleanup = parseComposedCleanupReceipt(composedPhasePayload(journal, "cleaned").receipt); + const world = parseComposedWorldEvidenceReceipt( + composedPhasePayload(journal, "world_evidence_exported").evidence, + ); + const organization = parseComposedOrganizationEvidenceReceipt( + composedPhasePayload(journal, "organization_evidence_exported").evidence, + ); + const topology = parseComposedTopologyAttestationReceipt( + composedPhasePayload(journal, "topology_verified").attestation, + ); + const activation = parseComposedTopologyActivationReceipt( + composedPhasePayload(journal, "activated").activation, + ); + const pause = parseComposedWorldPauseReceipt( + composedPhasePayload(journal, "world_paused").receipt, + ); + const service = parseComposedWorldServiceReceipt( + composedPhasePayload(journal, "world_started_paused").receipt, + ); + if (cleanup.run_id !== journal.request.run_id + || world.run_id !== journal.request.run_id + || world.pause_receipt_digest !== pause.receipt_digest + || world.source_service_handle !== service.service_handle + || organization.run_id !== journal.request.run_id + || organization.organization_phase_digest + !== journal.entries[composedRunPhaseIndex("organization_ready")]!.payload_digest + || topology.run_id !== journal.request.run_id + || activation.run_id !== journal.request.run_id + || activation.attestation_receipt_digest !== topology.receipt_digest + || activation.target_activation.topology_receipt_digest + !== topology.target_topology.receipt_digest + || activation.target_activation.topology_request_digest + !== topology.target_topology.request_digest) { + throw new TypeError("composed terminal topology evidence is invalid"); + } + const sealDigest = digestComposedJson("simfile.composed-run-evidence-seal.v1", { + organization: organization.inventory_digest, + world: world.inventory_digest, + }); + return createComposedTerminalReceipt({ + cleanup: { + receipt_digest: cleanup.receipt_digest, + remaining_owned_resources: [], + state: "cleaned", + }, + evidence: { + organization: { + authority: "organization", + digest: organization.inventory_digest, + item_count: organization.files.length + 1, + state: "exported", + }, + world: { + authority: "world", + digest: world.inventory_digest, + item_count: world.item_count, + state: "exported", + }, + }, + journal_digest: terminalJournalDigest(journal), + request: journal.request, + seal: { digest: sealDigest, state: "sealed" }, + target: { + preparation_receipt_digest: preparation.receipt_digest, + selected_target: { + fingerprint: preparation.selected_target.fingerprint, + handle: preparation.selected_target.handle, + }, + selector: journal.request.target.selector, + }, + topology: { + activation_receipt_digest: activation.receipt_digest, + request_digest: topology.target_topology.request_digest, + receipt_digest: topology.target_topology.receipt_digest, + }, + verdict: { + digest: digestComposedJson("simfile.composed-run-verdict.v1", { + cleanup: cleanup.receipt_digest, + seal: sealDigest, + terminal_tick: configuration.terminal_tick, + }), + state: "valid", + }, + }); +}; + +/** Executes or resumes the entire composed lifecycle from one verified journal. */ +export const executeComposedRun = async (input: Readonly<{ + configuration: ComposedRunConfiguration; + context: ComposedPhaseContext; + journal: unknown; + ports: ComposedRunPorts; + signal?: AbortSignal; +}>): Promise => { + let journal = parseComposedPhaseJournal(input.journal); + const signal = input.signal ?? new AbortController().signal; + if (journal.current_phase === "completed") { + return completedComposedRunFromJournal(journal); + } + if (!composedPhaseReached(journal, "prepared")) { + const preparation = verifyPreparation(await input.ports.preparation.prepareComposedRun({ + idempotency_key: preparationKey(journal), request: journal.request, + signal, + }), journal, input.configuration.topology_expectation); + journal = await commitComposedPhase(journal, "prepared", { + preparation, + preparation_receipt_digest: preparation.receipt_digest, + run_id: journal.request.run_id, + }, input.context); + } + const preparation = verifyPreparation( + composedPhasePayload(journal, "prepared").preparation, + journal, + input.configuration.topology_expectation, + ); + journal = await startComposedWorld({ + context: input.context, + journal, + port: input.ports.world, + preparation, + readiness_expectation: input.configuration.readiness_expectation, + signal, + }); + journal = await startComposedOrganization({ + context: input.context, + expectation: input.configuration.organization_expectation, + journal, + port: input.ports.organization, + signal, + }); + journal = await activateComposedTopology({ + context: input.context, + expectation: input.configuration.topology_expectation, + journal, + port: input.ports.topology, + signal, + }); + journal = await superviseComposedWorld({ + context: input.context, + expected_terminal_tick: input.configuration.terminal_tick, + journal, + operator_timeout_ms: input.configuration.operator_timeout_ms, + port: input.ports.supervision, + signal, + }); + journal = await finalizeComposedWorld({ + context: input.context, journal, port: input.ports.world_finalization, signal, + }); + journal = await finalizeComposedOrganization({ + context: input.context, + deployment_name: input.configuration.deployment_name, + journal, + port: input.ports.organization_finalization, + signal, + }); + journal = await cleanupComposedRun({ + context: input.context, journal, port: input.ports.cleanup, signal, + }); + const receipt = completeReceipt(journal, preparation, input.configuration); + journal = await commitComposedPhase(journal, "completed", { + receipt, receipt_digest: receipt.receipt_digest, run_id: journal.request.run_id, + }, input.context); + return { journal, receipt }; +}; diff --git a/src/compose/runRecord.test.ts b/src/compose/runRecord.test.ts new file mode 100644 index 0000000..962d8a7 --- /dev/null +++ b/src/compose/runRecord.test.ts @@ -0,0 +1,114 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { verifyManifestArtifacts } from "../observe/artifacts.js"; +import { findInProgressDynamicsRun } from "../view/runFollowLocator.js"; +import { COMPOSED_ARTIFACT_ROLES, createComposedRunRecord } from "./runRecord.js"; + +const bytes = (value: string): Uint8Array => new TextEncoder().encode(`${value}\n`); +const create = async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-composed-record-")); + const out = path.join(root, "run-one"); + const record = await createComposedRunRecord({ + identity: { + contract_versions: {}, created_at: "2026-08-07T12:00:00.000Z", + run_id: "run-one", spawnfile: { fingerprint: "sf1:test" }, + world: { terminal_tick: 40 }, + }, out_dir: out, + }); + return { out, record }; +}; +const complete = async (record: Awaited>["record"]) => { + for (const [index, role] of COMPOSED_ARTIFACT_ROLES.entries()) { + const artifactPath = role === "world-frame" ? "raw/frames.jsonl" + : `evidence/${role}.json`; + await record.writeArtifact({ bytes: bytes(`${role}-${index}`), path: artifactPath, role }); + } +}; + +describe("composed run record", () => { + it("atomically promotes one complete exact-hash inventory", async () => { + const { out, record } = await create(); + await complete(record); + assert.equal(path.basename((await findInProgressDynamicsRun(out))!), + path.basename(record.staging_dir)); + const sealed = await record.seal(); + assert.equal(sealed.out_dir, out); + assert.equal(await findInProgressDynamicsRun(out), undefined); + const checks = await verifyManifestArtifacts(out, sealed.manifest.artifacts); + assert.equal(checks.length, COMPOSED_ARTIFACT_ROLES.length + 1); + assert.equal(checks.every(({ ok }) => ok), true); + await stat(path.join(out, "manifest.json")); + }); + + it("refuses missing inventory roles and changed bytes", async () => { + const first = await create(); + await first.record.writeArtifact({ bytes: bytes("identity"), + path: "identity.json", role: "identity" }); + await assert.rejects(first.record.seal(), /inventory is incomplete/u); + await first.record.abort(); + + const second = await create(); + await complete(second.record); + await writeFile(path.join(second.record.staging_dir, "evidence/identity.json"), "tampered\n"); + await assert.rejects(second.record.seal(), /changed before seal/u); + await assert.rejects(readFile(path.join(second.out, "manifest.json"))); + await second.record.abort(); + }); + + it("rejects traversal, duplicates, reserved files, and output reuse", async () => { + const { out, record } = await create(); + for (const artifactPath of ["../escape", "/absolute", "manifest.json", "inventory.json"]) { + await assert.rejects(record.writeArtifact({ bytes: bytes("x"), + path: artifactPath, role: "identity" }), /path is invalid/u); + } + await record.writeArtifact({ bytes: bytes("x"), path: "identity.json", role: "identity" }); + await assert.rejects(record.writeArtifact({ bytes: bytes("x"), + path: "identity.json", role: "identity" }), /declaration is invalid/u); + await assert.rejects(createComposedRunRecord({ identity: { + contract_versions: {}, created_at: "2026-08-07T12:00:00.000Z", run_id: "run-one", + }, out_dir: out })); + await record.abort(); + }); + + it("adopts a group only after every artifact is durable", async () => { + const { record } = await create(); + const collision = path.join(record.staging_dir, "evidence", "collision.json"); + await mkdir(path.dirname(collision), { recursive: true }); + await writeFile(collision, "occupied\n", { flag: "wx" }); + await assert.rejects(record.writeArtifacts([ + { bytes: bytes("first"), path: "evidence/first.json", role: "provenance" }, + { bytes: bytes("second"), path: "evidence/collision.json", role: "presentation" }, + ])); + await assert.rejects(readFile(path.join(record.staging_dir, "evidence", "first.json"))); + assert.equal(await readFile(collision, "utf8"), "occupied\n"); + await record.writeArtifact({ bytes: bytes("first"), + path: "evidence/first.json", role: "provenance" }); + await record.abort(); + }); + + it("removes the current artifact when a durable write fails after creation", async () => { + const { record } = await create(); + const buffer = new ArrayBuffer(16); + const detached = new Uint8Array(buffer); + detached.set(new TextEncoder().encode("second\n")); + const pending = record.writeArtifacts([ + { bytes: bytes("first"), path: "evidence/first.json", role: "provenance" }, + { bytes: detached, path: "evidence/second.json", role: "presentation" }, + ]); + structuredClone(buffer, { transfer: [buffer] }); + await assert.rejects(pending, TypeError); + await assert.rejects(readFile(path.join(record.staging_dir, "evidence/first.json")), + /ENOENT/u); + await assert.rejects(readFile(path.join(record.staging_dir, "evidence/second.json")), + /ENOENT/u); + await record.writeArtifacts([ + { bytes: bytes("first"), path: "evidence/first.json", role: "provenance" }, + { bytes: bytes("second"), path: "evidence/second.json", role: "presentation" }, + ]); + await record.abort(); + }); +}); diff --git a/src/compose/runRecord.ts b/src/compose/runRecord.ts new file mode 100644 index 0000000..1983de9 --- /dev/null +++ b/src/compose/runRecord.ts @@ -0,0 +1,202 @@ +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, open, readFile, rename, rm, rmdir } from "node:fs/promises"; +import path from "node:path"; + +import { stableStringify } from "../ledger/stable.js"; +import { parseRunManifest, type SimfileRunManifest } from "../observe/manifest.js"; +import { dynamicsRunStagingPrefix } from "../run/dynamics-run-artifacts.js"; + +export const COMPOSED_RUN_INVENTORY_VERSION = "simfile.composed-run-inventory.v1" as const; +export const COMPOSED_ARTIFACT_ROLES = Object.freeze([ + "accepted-action", "action-result", "authority-export", "identity", + "presentation", "probe", "provenance", "terminal", "world-checkpoint", + "world-frame", +] as const); +export type ComposedArtifactRole = typeof COMPOSED_ARTIFACT_ROLES[number]; + +interface RecordIdentity { + readonly contract_versions: Readonly>; + readonly created_at: string; + readonly run_id: string; + readonly spawnfile?: SimfileRunManifest["spawnfile"]; + readonly world?: SimfileRunManifest["world"]; +} +export interface ComposedRunArtifactInput { + readonly bytes: Uint8Array; + readonly path: string; + readonly role: ComposedArtifactRole; +} +export interface ComposedRunRecord { + readonly out_dir: string; + readonly staging_dir: string; + abort(): Promise; + seal(): Promise>; + writeArtifact(input: ComposedRunArtifactInput): Promise; + writeArtifacts(inputs: readonly ComposedRunArtifactInput[]): Promise; +} + +const sha256 = (value: Uint8Array | string): string => + createHash("sha256").update(value).digest("hex"); +const ordered = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0; +const safeRelativePath = (value: string): string => { + if (value.length < 1 || value.length > 4_096 || path.isAbsolute(value) + || path.posix.normalize(value) !== value || value === "." + || value.split("/").some((part) => part.length === 0 || part === "..") + || value === "manifest.json" || value === "inventory.json") { + throw new TypeError("composed artifact path is invalid"); + } + return value; +}; +const exactIdentity = (raw: RecordIdentity): RecordIdentity => { + if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u.test(raw.run_id) + || !Number.isFinite(Date.parse(raw.created_at))) { + throw new TypeError("composed run record identity is invalid"); + } + return Object.freeze(raw); +}; +const durableWrite = async ( + filePath: string, + bytes: Uint8Array | string, +): Promise => { + const handle = await open(filePath, "wx", 0o600); + const failures: unknown[] = []; + try { + await handle.writeFile(bytes); + await handle.sync(); + } catch (failure) { failures.push(failure); } + try { await handle.close(); } catch (failure) { failures.push(failure); } + if (failures.length === 0) return; + try { await rm(filePath, { force: true }); } catch (failure) { failures.push(failure); } + if (failures.length === 1) throw failures[0]; + throw new AggregateError( + failures, + "composed artifact durable write failed; cleanup also failed", + ); +}; +const syncDirectory = async (directory: string): Promise => { + const handle = await open(directory, "r"); + try { await handle.sync(); } finally { await handle.close(); } +}; + +class RunRecord implements ComposedRunRecord { + readonly out_dir: string; + readonly staging_dir: string; + readonly #identity: RecordIdentity; + readonly #artifacts = new Map(); + #sealed = false; + constructor(outDir: string, stagingDir: string, identity: RecordIdentity) { + this.out_dir = outDir; this.staging_dir = stagingDir; this.#identity = identity; + } + async writeArtifact(input: ComposedRunArtifactInput): Promise { + await this.writeArtifacts([input]); + } + async writeArtifacts(inputs: readonly ComposedRunArtifactInput[]): Promise { + if (this.#sealed) throw new TypeError("composed run record is sealed"); + const prepared = inputs.map((input) => ({ input, + relative: safeRelativePath(input.path) })); + const paths = prepared.map(({ relative }) => relative); + if (prepared.length === 0 || new Set(paths).size !== paths.length + || prepared.some(({ input, relative }) => + !(COMPOSED_ARTIFACT_ROLES as readonly string[]).includes(input.role) + || this.#artifacts.has(relative) || input.bytes.byteLength > 1_073_741_824)) { + throw new TypeError("composed artifact declaration is invalid"); + } + const written: string[] = []; + try { + for (const { input, relative } of prepared) { + const target = path.join(this.staging_dir, relative); + await mkdir(path.dirname(target), { recursive: true, mode: 0o700 }); + await durableWrite(target, input.bytes); + written.push(target); + } + } catch (error) { + const cleanup = await Promise.allSettled( + written.map((target) => rm(target, { force: true })), + ); + const cleanupFailures = cleanup.flatMap((result) => + result.status === "rejected" ? [result.reason] : []); + if (cleanupFailures.length > 0) { + throw new AggregateError( + [error, ...cleanupFailures], + "composed artifact group write and rollback both failed", + ); + } + throw error; + } + for (const { input, relative } of prepared) { + this.#artifacts.set(relative, { role: input.role, sha256: sha256(input.bytes) }); + } + } + async seal(): Promise> { + if (this.#sealed) throw new TypeError("composed run record is sealed"); + const roles = new Set([...this.#artifacts.values()].map(({ role }) => role)); + const missing = COMPOSED_ARTIFACT_ROLES.filter((role) => !roles.has(role)); + if (missing.length > 0) { + throw new TypeError(`composed run inventory is incomplete: ${missing.join(", ")}`); + } + const entries = [...this.#artifacts.entries()].sort(([left], [right]) => ordered(left, right)); + for (const [relative, expected] of entries) { + if (sha256(await readFile(path.join(this.staging_dir, relative))) !== expected.sha256) { + throw new TypeError(`composed run artifact changed before seal: ${relative}`); + } + } + const inventoryBytes = `${stableStringify({ + artifacts: entries.map(([artifactPath, entry]) => ({ + path: artifactPath, role: entry.role, sha256: entry.sha256, + })), + run_id: this.#identity.run_id, + version: COMPOSED_RUN_INVENTORY_VERSION, + })}\n`; + await durableWrite(path.join(this.staging_dir, "inventory.json"), inventoryBytes); + const artifacts = [ + ...entries.map(([artifactPath, entry]) => ({ path: artifactPath, sha256: entry.sha256 })), + { path: "inventory.json", sha256: sha256(inventoryBytes) }, + ].sort((left, right) => ordered(left.path, right.path)); + const manifest = parseRunManifest({ + artifacts, + contract_versions: { + ...this.#identity.contract_versions, + [COMPOSED_RUN_INVENTORY_VERSION]: COMPOSED_RUN_INVENTORY_VERSION, + "simfile.run-manifest.v1": "simfile.run-manifest.v1", + }, + created_at: this.#identity.created_at, + run_id: this.#identity.run_id, + ...(this.#identity.spawnfile === undefined ? {} : { spawnfile: this.#identity.spawnfile }), + version: "simfile.run-manifest.v1", + ...(this.#identity.world === undefined ? {} : { world: this.#identity.world }), + }); + const manifestBytes = `${stableStringify(manifest)}\n`; + await durableWrite(path.join(this.staging_dir, "manifest.json"), manifestBytes); + await syncDirectory(this.staging_dir); + await rename(this.staging_dir, this.out_dir); + await syncDirectory(path.dirname(this.out_dir)); + this.#sealed = true; + return Object.freeze({ manifest, manifest_sha256: sha256(manifestBytes), out_dir: this.out_dir }); + } + async abort(): Promise { + if (this.#sealed) return; + await rm(this.staging_dir, { force: true, recursive: true }); + await rmdir(this.out_dir).catch(() => undefined); + } +} + +/** Reserves one output and creates the only live-follow staging directory. */ +export const createComposedRunRecord = async (input: Readonly<{ + identity: RecordIdentity; + out_dir: string; +}>): Promise => { + const outDir = path.resolve(input.out_dir); + if (outDir === path.parse(outDir).root) throw new TypeError("composed output path is invalid"); + const identity = exactIdentity(input.identity); + await mkdir(path.dirname(outDir), { recursive: true }); + await mkdir(outDir); + try { + const staging = await mkdtemp(path.join(path.dirname(outDir), dynamicsRunStagingPrefix(outDir))); + return new RunRecord(outDir, staging, identity); + } catch (error) { + await rmdir(outDir).catch(() => undefined); + throw error; + } +}; diff --git a/src/compose/startup-organization.test.ts b/src/compose/startup-organization.test.ts new file mode 100644 index 0000000..9986544 --- /dev/null +++ b/src/compose/startup-organization.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ComposedPhaseJournal } from "./journal.js"; +import { + lifecycleDigest, + lifecycleOrganizationExpectation, + lifecycleOrganizationUpReceipt, + lifecyclePhaseContext, + lifecycleRequest, + preparedLifecycleJournal, + worldReadyLifecycleJournal, +} from "./lifecycle.test-helper.js"; +import { + startComposedOrganization, + verifyComposedOrganizationUpReceipt, + type ComposedOrganizationStartupPort, +} from "./startup-organization.js"; + +const expectation = lifecycleOrganizationExpectation; +const upReceipt = lifecycleOrganizationUpReceipt; + +const fakePort = (runId: string) => { + const calls = { ready: 0, start: 0 }; + const port: ComposedOrganizationStartupPort = { + readOrganizationReadiness: async () => { calls.ready += 1; return upReceipt(runId, true); }, + startOrganization: async () => { calls.start += 1; return upReceipt(runId, false); }, + }; + return { calls, port }; +}; + +test("organization starts second and verifies exact binding and pinned Moltnet identity", async () => { + const request = lifecycleRequest(); + const fake = fakePort(request.run_id); + const journal = await startComposedOrganization({ + context: lifecyclePhaseContext().context, + expectation: expectation(), + journal: worldReadyLifecycleJournal(request), + port: fake.port, + }); + assert.equal(journal.current_phase, "organization_ready"); + assert.deepEqual(fake.calls, { ready: 1, start: 1 }); +}); + +test("organization startup resumes each boundary without duplicate starts or probes", async () => { + for (const failedPhase of ["organization_started", "organization_ready"] as const) { + const request = lifecycleRequest({ run_id: `run-${failedPhase}` }); + const fake = fakePort(request.run_id); + const persisted: ComposedPhaseJournal[] = []; + let injected = false; + await assert.rejects(startComposedOrganization({ + context: lifecyclePhaseContext({ + afterPhase: (phase) => { + if (!injected && phase === failedPhase) { + injected = true; + throw new Error(`fault after ${phase}`); + } + }, + persisted, + }).context, + expectation: expectation(), + journal: worldReadyLifecycleJournal(request), + port: fake.port, + }), /fault after/u); + const resumed = await startComposedOrganization({ + context: lifecyclePhaseContext().context, + expectation: expectation(), + journal: persisted.at(-1), + port: fake.port, + }); + assert.equal(resumed.current_phase, "organization_ready"); + assert.deepEqual(fake.calls, { ready: 1, start: 1 }); + } +}); + +test("organization startup rejects ordering, binding, run, release, and cognition criteria", async () => { + const request = lifecycleRequest(); + const fake = fakePort(request.run_id); + await assert.rejects(startComposedOrganization({ + context: lifecyclePhaseContext().context, + expectation: expectation(), + journal: preparedLifecycleJournal(request), + port: fake.port, + }), /requires world readiness/u); + for (const raw of [ + { ...upReceipt(request.run_id, true), run_id: "run-foreign" }, + { + ...upReceipt(request.run_id, true), + organization_ready: { + ...upReceipt(request.run_id, true).organization_ready, + world_binding_digest: lifecycleDigest("f"), + }, + }, + { + ...upReceipt(request.run_id, true), + organization_ready: { + ...upReceipt(request.run_id, true).organization_ready, + unit_id: "organization-unit", + }, + }, + { ...upReceipt(request.run_id, true), moltnet_release: undefined }, + { + ...upReceipt(request.run_id, true), + moltnet_release: { ...upReceipt(request.run_id, true).moltnet_release, capabilities: [] }, + }, + { + ...upReceipt(request.run_id, true), + moltnet_release: { ...upReceipt(request.run_id, true).moltnet_release, release_version: "latest" }, + }, + { + ...upReceipt(request.run_id, true), + organization_handoff: { + ...upReceipt(request.run_id, true).organization_handoff, + deployment_handle: `sf-oh1-${"0".repeat(64)}`, + }, + }, + { ...upReceipt(request.run_id, true), agent_response_count: 2 }, + ]) assert.throws(() => verifyComposedOrganizationUpReceipt({ + expectation: expectation(), raw, require_ready: true, run_id: request.run_id, + }), /correlation|readiness|agent-response|invalid|expected/u); +}); diff --git a/src/compose/startup-organization.ts b/src/compose/startup-organization.ts new file mode 100644 index 0000000..0407207 --- /dev/null +++ b/src/compose/startup-organization.ts @@ -0,0 +1,249 @@ +import { createHash } from "node:crypto"; + +import { z } from "zod"; +import { targetResourceReceiptSchema } from "../spawnfile/targetReceipts.js"; + +import { assertSecretFreeComposedJson, digestComposedJson } from "./json.js"; +import { parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; +import { + commitComposedPhase, + composedPhasePayload, + composedPhaseReached, + type ComposedPhaseContext, +} from "./phase.js"; + +const digest = z.string().regex(/^sha256:[a-f0-9]{64}$/u); +const runId = z.string().regex(/^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/u); +const opaque = z.string().regex(/^opaque_[a-z0-9]{16,64}$/u); +const readiness = z.object({ + code: z.literal("organization_ready"), + compile_fingerprint: z.string().regex(/^sf1:[a-f0-9]{12}$/u), + run_id: runId, + state: z.literal("ready"), + unit_id: runId, + version: z.literal("spawnfile.organization-ready.v1"), + world_binding_digest: digest, +}).strict(); +const release = z.object({ + architecture: z.enum(["amd64", "arm64"]), + asset: z.string().regex(/^moltnet_linux_(?:amd64|arm64)\.tar\.gz$/u), + asset_sha256: digest, + capabilities: z.tuple([z.literal("pi-bridge")]), + release_version: z.string().regex(/^v?\d+\.\d+\.\d+(?:-\d+-g[a-f0-9]{7,40})?$/u), + source_revision: z.string().regex(/^[a-f0-9]{40}$/u), + version: z.literal("spawnfile.moltnet-release-identity.v1"), +}).strict().superRefine((value, context) => { + if (value.asset !== `moltnet_linux_${value.architecture}.tar.gz`) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "Moltnet asset architecture is invalid", + path: ["asset"], + }); + } + const describedRevision = value.release_version.match(/-g([a-f0-9]{7,40})$/u)?.[1]; + if (describedRevision && !value.source_revision.startsWith(describedRevision)) { + context.addIssue({ + code: z.ZodIssueCode.custom, + message: "Moltnet release version does not describe its source revision", + path: ["source_revision"], + }); + } +}); +const handoff = z.object({ + binding_digest: digest, + deployment_handle: z.string().regex(/^sf-oh1-[a-f0-9]{64}$/u), + lifecycle_receipts: z.object({ + down: z.literal("spawnfile.down-receipt.v1"), + export: z.literal("spawnfile.export-index.v1"), + up: z.literal("spawnfile.up-receipt.v1"), + }).strict(), + network_attachment_handle: opaque, + run_id: runId, + selected_target_receipt_digest: digest, + version: z.literal("spawnfile.organization-handoff.v1"), +}).strict(); +const upReceipt = z.object({ + compiled_schedule: z.array(z.object({ agent: runId, cron: z.string().min(1) }).passthrough()), + deployment: z.object({ + container_ids: z.array(runId).min(1), + name: runId, + }).passthrough(), + engines: z.array(z.object({ agent: runId, engine: runId }).passthrough()).min(1), + fingerprint: z.string().regex(/^sf1:[a-f0-9]{12}$/u), + moltnet_release: release, + organization_handoff: handoff, + organization_handoff_handle: opaque, + organization_ready: readiness.optional(), + readiness: z.object({ + moltnet_base_url: z.string().url(), + state: z.literal("running"), + }).passthrough(), + run_id: runId, + target_attachment: targetResourceReceiptSchema.optional(), + version: z.literal("spawnfile.up-receipt.v1"), +}).passthrough(); + +export type ComposedOrganizationUpReceipt = z.infer; + +export interface ComposedOrganizationExpectation { + readonly deployment_name: string; + readonly member_engines: Readonly>; + readonly moltnet_release: Readonly<{ + architecture: "amd64" | "arm64"; + asset_sha256: string; + release_version: string; + source_revision: string; + }>; + readonly selected_target_receipt_digest: string; + readonly unit_id: string; + readonly world_binding_digest: string; +} + +export interface ComposedOrganizationStartupPort { + startOrganization(input: Readonly<{ + idempotency_key: string; + run_id: string; + signal: AbortSignal; + world_readiness_digest: string; + }>): Promise; + readOrganizationReadiness(input: Readonly<{ + up_receipt: ComposedOrganizationUpReceipt; + signal: AbortSignal; + }>): Promise; +} + +export const deriveComposedOrganizationDeploymentHandle = ( + value: Omit, "deployment_handle" | "version">, +): string => { + const canonical = [ + "spawnfile.organization-handoff.v1\0", + value.run_id, + value.selected_target_receipt_digest, + value.network_attachment_handle, + value.binding_digest, + value.lifecycle_receipts.up, + value.lifecycle_receipts.export, + value.lifecycle_receipts.down, + ].join("\n"); + return `sf-oh1-${createHash("sha256").update(canonical, "utf8").digest("hex")}`; +}; + +const noCognitionCriterion = (value: unknown): void => { + if (value && typeof value === "object" && !Array.isArray(value)) { + for (const [key, nested] of Object.entries(value)) { + if (/(?:agent|participant).*(?:reply|response|turn|action)|(?:reply|response).*count/iu.test(key)) { + throw new TypeError("organization readiness contains an agent-response criterion"); + } + noCognitionCriterion(nested); + } + } else if (Array.isArray(value)) value.forEach(noCognitionCriterion); +}; + +export const verifyComposedOrganizationUpReceipt = (input: Readonly<{ + expectation: ComposedOrganizationExpectation; + raw: unknown; + require_ready: boolean; + run_id: string; +}>): ComposedOrganizationUpReceipt => { + assertSecretFreeComposedJson(input.raw); + noCognitionCriterion(input.raw); + const receipt = upReceipt.parse(input.raw); + const expectedEngines = Object.entries(input.expectation.member_engines) + .sort(([left], [right]) => left.localeCompare(right)); + const actualEngines = receipt.engines.map(({ agent, engine }) => [agent, engine] as const) + .sort(([left], [right]) => left.localeCompare(right)); + const releaseExpected = input.expectation.moltnet_release; + const { deployment_handle: _deploymentHandle, version: _handoffVersion, ...handoffBody } + = receipt.organization_handoff; + if (receipt.run_id !== input.run_id + || receipt.deployment.name !== input.expectation.deployment_name + || new Set(receipt.deployment.container_ids).size !== receipt.deployment.container_ids.length + || JSON.stringify(actualEngines) !== JSON.stringify(expectedEngines) + || receipt.moltnet_release.architecture !== releaseExpected.architecture + || receipt.moltnet_release.asset !== `moltnet_linux_${releaseExpected.architecture}.tar.gz` + || receipt.moltnet_release.asset_sha256 !== releaseExpected.asset_sha256 + || receipt.moltnet_release.release_version !== releaseExpected.release_version + || receipt.moltnet_release.source_revision !== releaseExpected.source_revision + || receipt.organization_handoff.run_id !== input.run_id + || receipt.organization_handoff.binding_digest !== input.expectation.world_binding_digest + || receipt.organization_handoff.selected_target_receipt_digest + !== input.expectation.selected_target_receipt_digest + || receipt.organization_handoff.deployment_handle + !== deriveComposedOrganizationDeploymentHandle(handoffBody)) { + throw new TypeError("composed organization receipt correlation is invalid"); + } + if (input.require_ready && (!receipt.organization_ready + || receipt.organization_ready.run_id !== input.run_id + || receipt.organization_ready.world_binding_digest !== input.expectation.world_binding_digest + || receipt.organization_ready.compile_fingerprint !== receipt.fingerprint + || receipt.organization_ready.unit_id !== input.expectation.unit_id)) { + throw new TypeError("composed organization readiness is invalid"); + } + return Object.freeze(receipt); +}; + +const operationKey = (journal: ComposedPhaseJournal, operation: string): string => + `idem_${digestComposedJson("simfile.composed-organization-operation.v1", { + operation, request_digest: journal.request_digest, + }).slice(7, 39)}`; + +/** Starts the organization only after world-only readiness, then proves exact bindings. */ +export const startComposedOrganization = async (input: Readonly<{ + context: ComposedPhaseContext; + expectation: ComposedOrganizationExpectation; + journal: unknown; + port: ComposedOrganizationStartupPort; + signal?: AbortSignal; +}>): Promise => { + let journal = parseComposedPhaseJournal(input.journal); + if (!composedPhaseReached(journal, "world_ready")) { + throw new TypeError("composed organization startup requires world readiness"); + } + const worldReadinessDigest = composedPhasePayload(journal, "world_ready").readiness_digest; + if (typeof worldReadinessDigest !== "string") { + throw new TypeError("composed world readiness digest is unavailable"); + } + if (!composedPhaseReached(journal, "organization_started")) { + const upReceiptValue = verifyComposedOrganizationUpReceipt({ + expectation: input.expectation, + raw: await input.port.startOrganization({ + idempotency_key: operationKey(journal, "start_organization"), + run_id: journal.request.run_id, + signal: input.signal ?? new AbortController().signal, + world_readiness_digest: worldReadinessDigest, + }), + require_ready: false, + run_id: journal.request.run_id, + }); + journal = await commitComposedPhase(journal, "organization_started", { + run_id: journal.request.run_id, + up_receipt: upReceiptValue, + up_receipt_digest: digestComposedJson("spawnfile.up-receipt.v1", upReceiptValue), + }, input.context); + } + if (!composedPhaseReached(journal, "organization_ready")) { + const started = verifyComposedOrganizationUpReceipt({ + expectation: input.expectation, + raw: composedPhasePayload(journal, "organization_started").up_receipt, + require_ready: false, + run_id: journal.request.run_id, + }); + const ready = verifyComposedOrganizationUpReceipt({ + expectation: input.expectation, + raw: await input.port.readOrganizationReadiness({ + signal: input.signal ?? new AbortController().signal, + up_receipt: started, + }), + require_ready: true, + run_id: journal.request.run_id, + }); + journal = await commitComposedPhase(journal, "organization_ready", { + moltnet_release: ready.moltnet_release, + organization_handoff: ready.organization_handoff, + readiness: ready.organization_ready, + receipt_digest: digestComposedJson("spawnfile.up-receipt.v1", ready), + run_id: journal.request.run_id, + }, input.context); + } + return journal; +}; diff --git a/src/compose/startup-world.test.ts b/src/compose/startup-world.test.ts new file mode 100644 index 0000000..21f89a5 --- /dev/null +++ b/src/compose/startup-world.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + lifecycleHandle, + lifecyclePhaseContext, + lifecyclePreparation, + lifecycleReadiness, + lifecycleReadinessExpectation, + lifecycleRequest, + preparedLifecycleJournal, +} from "./lifecycle.test-helper.js"; +import { + createComposedWorldResourceReceipt, + createComposedWorldServiceReceipt, + startComposedWorld, + type ComposedWorldStartupPort, +} from "./startup-world.js"; +import type { ComposedRunPhase } from "./types.js"; +import type { ComposedPhaseJournal } from "./journal.js"; + +const fakePort = (request = lifecycleRequest()) => { + const calls = { create: 0, organization: 0, readiness: 0, release: 0, start: 0 }; + const preparation = lifecyclePreparation(request); + const resource = createComposedWorldResourceReceipt({ + artifact_digest: request.world.artifact_manifest_digest, + bundle_digest: request.world.bundle_digest, + preparation_receipt_digest: preparation.receipt_digest, + resource_handle: lifecycleHandle("2"), + run_id: request.run_id, + }); + const service = createComposedWorldServiceReceipt({ + resource_handle: resource.resource_handle, + run_id: request.run_id, + service_handle: lifecycleHandle("3"), + }); + const port: ComposedWorldStartupPort = { + createWorldResource: async () => { calls.create += 1; return resource; }, + readWorldReadiness: async () => { calls.readiness += 1; return lifecycleReadiness(request); }, + startWorldPaused: async () => { calls.start += 1; return service; }, + }; + return { calls, port, preparation }; +}; + +test("world starts paused and proves pristine readiness with organization absent", async () => { + const request = lifecycleRequest(); + const fake = fakePort(request); + const harness = lifecyclePhaseContext(); + const journal = await startComposedWorld({ + context: harness.context, + journal: preparedLifecycleJournal(request), + port: fake.port, + preparation: fake.preparation, + readiness_expectation: lifecycleReadinessExpectation(request), + }); + assert.equal(journal.current_phase, "world_ready"); + assert.deepEqual(fake.calls, { + create: 1, organization: 0, readiness: 1, release: 0, start: 1, + }); + assert.equal(lifecycleReadiness(request).clock.next_tick, 0); + assert.throws(() => { + if (fake.calls.release === 0) throw new Error("world clock is not activated"); + }, /not activated/u); +}); + +test("every world startup boundary resumes without repeating completed operations", async () => { + for (const failedPhase of [ + "world_created", "world_started_paused", "world_ready", + ] as const satisfies readonly ComposedRunPhase[]) { + const request = lifecycleRequest({ run_id: `run-${failedPhase}` }); + const fake = fakePort(request); + const persisted: ComposedPhaseJournal[] = []; + let failed = false; + const first = lifecyclePhaseContext({ + afterPhase: (phase) => { + if (!failed && phase === failedPhase) { + failed = true; + throw new Error(`fault after ${phase}`); + } + }, + persisted, + }); + await assert.rejects(startComposedWorld({ + context: first.context, + journal: preparedLifecycleJournal(request), + port: fake.port, + preparation: fake.preparation, + readiness_expectation: lifecycleReadinessExpectation(request), + }), /fault after/u); + const durable = persisted.at(-1)!; + const resumed = await startComposedWorld({ + context: lifecyclePhaseContext().context, + journal: durable, + port: fake.port, + preparation: fake.preparation, + readiness_expectation: lifecycleReadinessExpectation(request), + }); + assert.equal(resumed.current_phase, "world_ready"); + assert.deepEqual(fake.calls, { + create: 1, organization: 0, readiness: 1, release: 0, start: 1, + }, failedPhase); + } +}); + +test("world startup rejects forged preparation and non-pristine readiness", async () => { + const request = lifecycleRequest(); + const fake = fakePort(request); + await assert.rejects(startComposedWorld({ + context: lifecyclePhaseContext().context, + journal: preparedLifecycleJournal(request), + port: fake.port, + preparation: { ...fake.preparation, run_id: "run-foreign" }, + readiness_expectation: lifecycleReadinessExpectation(request), + }), /preparation correlation/u); + const badPort: ComposedWorldStartupPort = { + ...fake.port, + readWorldReadiness: async () => ({ + ...lifecycleReadiness(request), + clock: { next_tick: 1, state: "running" }, + }), + }; + await assert.rejects(startComposedWorld({ + context: lifecyclePhaseContext().context, + journal: preparedLifecycleJournal(request), + port: badPort, + preparation: fake.preparation, + readiness_expectation: lifecycleReadinessExpectation(request), + }), /paused and pristine/u); +}); diff --git a/src/compose/startup-world.ts b/src/compose/startup-world.ts new file mode 100644 index 0000000..3dd6636 --- /dev/null +++ b/src/compose/startup-world.ts @@ -0,0 +1,175 @@ +import { z } from "zod"; +import { targetResourceReceiptSchema } from "../spawnfile/targetReceipts.js"; + +import { + verifyWorldSidecarReadiness, + type WorldSidecarReadiness, + type WorldSidecarReadinessExpectation, +} from "../world-artifact/readiness.js"; +import { + composedDigestSchema, + composedHandleSchema, + composedRunIdSchema, + parseComposedDigestedContract, + sealComposedContract, +} from "./contracts.js"; +import { digestComposedJson } from "./json.js"; +import { parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; +import { + commitComposedPhase, + composedPhasePayload, + composedPhaseReached, + type ComposedPhaseContext, +} from "./phase.js"; + +export const COMPOSED_WORLD_RESOURCE_VERSION = "simfile.composed-world-resource.v1" as const; +export const COMPOSED_WORLD_SERVICE_VERSION = "simfile.composed-world-service.v1" as const; + +const worldResourceSchema = z.object({ + artifact_digest: composedDigestSchema, + bundle_digest: composedDigestSchema, + preparation_receipt_digest: composedDigestSchema, + receipt_digest: composedDigestSchema, + resource_handle: composedHandleSchema, + run_id: composedRunIdSchema, + target_operation: targetResourceReceiptSchema.optional(), + version: z.literal(COMPOSED_WORLD_RESOURCE_VERSION), +}).strict(); +const worldServiceSchema = z.object({ + receipt_digest: composedDigestSchema, + resource_handle: composedHandleSchema, + run_id: composedRunIdSchema, + service_handle: composedHandleSchema, + state: z.literal("paused"), + target_operation: targetResourceReceiptSchema.optional(), + version: z.literal(COMPOSED_WORLD_SERVICE_VERSION), +}).strict(); + +export type ComposedWorldResourceReceipt = z.infer; +export type ComposedWorldServiceReceipt = z.infer; + +export const createComposedWorldResourceReceipt = ( + fields: Omit, +): ComposedWorldResourceReceipt => parseComposedWorldResourceReceipt(sealComposedContract( + COMPOSED_WORLD_RESOURCE_VERSION, + { ...fields, version: COMPOSED_WORLD_RESOURCE_VERSION }, +)); +export const parseComposedWorldResourceReceipt = (raw: unknown): ComposedWorldResourceReceipt => + parseComposedDigestedContract(raw, worldResourceSchema, + COMPOSED_WORLD_RESOURCE_VERSION, "composed world resource receipt"); +export const createComposedWorldServiceReceipt = ( + fields: Omit, +): ComposedWorldServiceReceipt => parseComposedWorldServiceReceipt(sealComposedContract( + COMPOSED_WORLD_SERVICE_VERSION, + { ...fields, state: "paused", version: COMPOSED_WORLD_SERVICE_VERSION }, +)); +export const parseComposedWorldServiceReceipt = (raw: unknown): ComposedWorldServiceReceipt => + parseComposedDigestedContract(raw, worldServiceSchema, + COMPOSED_WORLD_SERVICE_VERSION, "composed world service receipt"); + +export interface WorldStartupPreparation { + readonly receipt_digest: string; + readonly run_id: string; + readonly world: Readonly<{ + readonly artifact_manifest_digest: string; + readonly bundle_digest: string; + }>; +} + +export interface ComposedWorldStartupPort { + createWorldResource(input: Readonly<{ + idempotency_key: string; + preparation: WorldStartupPreparation; + signal: AbortSignal; + }>): Promise; + startWorldPaused(input: Readonly<{ + idempotency_key: string; + resource: ComposedWorldResourceReceipt; + signal: AbortSignal; + }>): Promise; + readWorldReadiness(input: Readonly<{ + service: ComposedWorldServiceReceipt; + signal: AbortSignal; + }>): Promise; +} + +const operationKey = (journal: ComposedPhaseJournal, operation: string): string => + `idem_${digestComposedJson("simfile.composed-world-operation.v1", { + operation, request_digest: journal.request_digest, + }).slice("sha256:".length, "sha256:".length + 32)}`; + +const resourceFromJournal = (journal: ComposedPhaseJournal): ComposedWorldResourceReceipt => + parseComposedWorldResourceReceipt(composedPhasePayload(journal, "world_created").receipt); +const serviceFromJournal = (journal: ComposedPhaseJournal): ComposedWorldServiceReceipt => + parseComposedWorldServiceReceipt(composedPhasePayload(journal, "world_started_paused").receipt); + +/** Advances only the world through paused, pristine, organization-absent readiness. */ +export const startComposedWorld = async (input: Readonly<{ + context: ComposedPhaseContext; + journal: unknown; + port: ComposedWorldStartupPort; + preparation: WorldStartupPreparation; + readiness_expectation: WorldSidecarReadinessExpectation; + signal?: AbortSignal; +}>): Promise => { + let journal = parseComposedPhaseJournal(input.journal); + if (!composedPhaseReached(journal, "prepared")) { + throw new TypeError("composed world startup requires prepared resources"); + } + if (input.preparation.run_id !== journal.request.run_id + || input.preparation.receipt_digest + !== composedPhasePayload(journal, "prepared").preparation_receipt_digest + || input.preparation.world.artifact_manifest_digest + !== journal.request.world.artifact_manifest_digest + || input.preparation.world.bundle_digest !== journal.request.world.bundle_digest) { + throw new TypeError("composed world preparation correlation is invalid"); + } + if (!composedPhaseReached(journal, "world_created")) { + const receipt = parseComposedWorldResourceReceipt(await input.port.createWorldResource({ + idempotency_key: operationKey(journal, "create_world_resource"), + preparation: input.preparation, + signal: input.signal ?? new AbortController().signal, + })); + if (receipt.run_id !== journal.request.run_id + || receipt.preparation_receipt_digest !== input.preparation.receipt_digest + || receipt.artifact_digest !== journal.request.world.artifact_manifest_digest + || receipt.bundle_digest !== journal.request.world.bundle_digest) { + throw new TypeError("composed world resource correlation is invalid"); + } + journal = await commitComposedPhase(journal, "world_created", { + receipt, run_id: journal.request.run_id, + }, input.context); + } + const resource = resourceFromJournal(journal); + if (!composedPhaseReached(journal, "world_started_paused")) { + const receipt = parseComposedWorldServiceReceipt(await input.port.startWorldPaused({ + idempotency_key: operationKey(journal, "start_world_paused"), resource, + signal: input.signal ?? new AbortController().signal, + })); + if (receipt.run_id !== journal.request.run_id + || receipt.resource_handle !== resource.resource_handle) { + throw new TypeError("composed world service correlation is invalid"); + } + journal = await commitComposedPhase(journal, "world_started_paused", { + receipt, run_id: journal.request.run_id, + }, input.context); + } + if (!composedPhaseReached(journal, "world_ready")) { + const readiness: WorldSidecarReadiness = verifyWorldSidecarReadiness( + await input.port.readWorldReadiness({ + service: serviceFromJournal(journal), + signal: input.signal ?? new AbortController().signal, + }), + input.readiness_expectation, + ); + if (readiness.run_id !== journal.request.run_id) { + throw new TypeError("composed world readiness run is invalid"); + } + journal = await commitComposedPhase(journal, "world_ready", { + readiness, + readiness_digest: digestComposedJson("simfile.composed-world-readiness.v1", readiness), + run_id: journal.request.run_id, + }, input.context); + } + return journal; +}; diff --git a/src/compose/superviseServices.test.ts b/src/compose/superviseServices.test.ts new file mode 100644 index 0000000..6fac193 --- /dev/null +++ b/src/compose/superviseServices.test.ts @@ -0,0 +1,52 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { createComposedWorldTerminalReceipt, + superviseComposedWorld } from "./supervision.js"; +import { lifecyclePhaseContext, tickOneLifecycleJournal } from "./lifecycle.test-helper.js"; + +const atTickOne = () => { + const persisted: ReturnType[] = []; + return { ...lifecyclePhaseContext({ persisted }), journal: tickOneLifecycleJournal() }; +}; + +describe("service-only composed supervision", () => { + it("accepts world terminal truth without consulting behavior", async () => { + const { context, journal } = atTickOne(); + let receivedSignal: AbortSignal | undefined; + const result = await superviseComposedWorld({ + context, expected_terminal_tick: 40, journal, operator_timeout_ms: 1_000, + port: { waitForWorldTerminal: async ({ running, signal }) => { + receivedSignal = signal; + return createComposedWorldTerminalReceipt({ + outcome_digest: `sha256:${"a".repeat(64)}`, reason: "completed", + run_id: running.run_id, running_receipt_digest: running.receipt_digest, + terminal_tick: 40, + }); + } }, + }); + assert.equal(result.current_phase, "terminal"); + assert.equal(receivedSignal?.aborted, false); + }); + + it("fails on operator timeout without turning it into world truth", async () => { + const { context, journal, persisted } = atTickOne(); + await assert.rejects(superviseComposedWorld({ + context, expected_terminal_tick: 40, journal, operator_timeout_ms: 5, + port: { waitForWorldTerminal: () => new Promise(() => undefined) }, + }), /operator timeout/u); + assert.equal(persisted.at(-1)?.current_phase, "running"); + }); + + it("honors an operator signal independently of service completion", async () => { + const { context, journal } = atTickOne(); + const controller = new AbortController(); + const pending = superviseComposedWorld({ + context, expected_terminal_tick: 40, journal, operator_timeout_ms: 1_000, + port: { waitForWorldTerminal: () => new Promise(() => undefined) }, + signal: controller.signal, + }); + controller.abort(new Error("operator interrupted")); + await assert.rejects(pending, /operator interrupted/u); + }); +}); diff --git a/src/compose/supervision.test.ts b/src/compose/supervision.test.ts new file mode 100644 index 0000000..b24bf97 --- /dev/null +++ b/src/compose/supervision.test.ts @@ -0,0 +1,108 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { ComposedPhaseJournal } from "./journal.js"; +import { + lifecycleDigest, + lifecyclePhaseContext, + lifecycleRequest, + organizationReadyLifecycleJournal, + tickOneLifecycleJournal, +} from "./lifecycle.test-helper.js"; +import { + createComposedWorldTerminalReceipt, + superviseComposedWorld, + type ComposedSupervisionPort, +} from "./supervision.js"; + +const fakePort = (journal: ComposedPhaseJournal, terminalTick = 4) => { + const calls = { terminal: 0 }; + const port: ComposedSupervisionPort = { + waitForWorldTerminal: async ({ running }) => { + calls.terminal += 1; + return createComposedWorldTerminalReceipt({ + outcome_digest: lifecycleDigest("0"), + reason: "completed", + run_id: journal.request.run_id, + running_receipt_digest: running.receipt_digest, + terminal_tick: terminalTick, + }); + }, + }; + return { calls, port }; +}; + +test("world supervision advances an exact tick horizon without cognition", async () => { + const initial = tickOneLifecycleJournal(); + const fake = fakePort(initial); + const journal = await superviseComposedWorld({ + context: lifecyclePhaseContext().context, + expected_terminal_tick: 4, + journal: initial, + port: fake.port, + }); + assert.equal(journal.current_phase, "terminal"); + assert.deepEqual(fake.calls, { terminal: 1 }); +}); + +test("world supervision resumes both boundaries without waiting twice", async () => { + for (const failedPhase of ["running", "terminal"] as const) { + const request = lifecycleRequest({ run_id: `run-${failedPhase}` }); + const initial = tickOneLifecycleJournal(request); + const fake = fakePort(initial); + const persisted: ComposedPhaseJournal[] = []; + await assert.rejects(superviseComposedWorld({ + context: lifecyclePhaseContext({ + afterPhase: (phase) => { + if (phase === failedPhase) throw new Error(`fault after ${phase}`); + }, + persisted, + }).context, + expected_terminal_tick: 4, + journal: initial, + port: fake.port, + }), /fault after/u); + const journal = await superviseComposedWorld({ + context: lifecyclePhaseContext().context, + expected_terminal_tick: 4, + journal: persisted.at(-1), + port: fake.port, + }); + assert.equal(journal.current_phase, "terminal"); + assert.deepEqual(fake.calls, { terminal: 1 }); + } +}); + +test("world supervision rejects early, stale, cross-run, and forged terminal proofs", async () => { + const initial = tickOneLifecycleJournal(); + await assert.rejects(superviseComposedWorld({ + context: lifecyclePhaseContext().context, + expected_terminal_tick: 4, + journal: organizationReadyLifecycleJournal(), + port: fakePort(initial).port, + }), /requires tick 1/u); + await assert.rejects(superviseComposedWorld({ + context: lifecyclePhaseContext().context, + expected_terminal_tick: 3, + journal: initial, + port: fakePort(initial, 4).port, + }), /correlation/u); + const forged: ComposedSupervisionPort = { + waitForWorldTerminal: async ({ running }) => ({ + ...createComposedWorldTerminalReceipt({ + outcome_digest: lifecycleDigest("0"), + reason: "completed", + run_id: "run-foreign", + running_receipt_digest: running.receipt_digest, + terminal_tick: 4, + }), + receipt_digest: lifecycleDigest("f"), + }), + }; + await assert.rejects(superviseComposedWorld({ + context: lifecyclePhaseContext().context, + expected_terminal_tick: 4, + journal: initial, + port: forged, + }), /digest/u); +}); diff --git a/src/compose/supervision.ts b/src/compose/supervision.ts new file mode 100644 index 0000000..9a0a881 --- /dev/null +++ b/src/compose/supervision.ts @@ -0,0 +1,161 @@ +import { z } from "zod"; + +import { + composedDigestSchema, + composedRunIdSchema, + parseComposedDigestedContract, + sealComposedContract, +} from "./contracts.js"; +import { parseComposedPhaseJournal, type ComposedPhaseJournal } from "./journal.js"; +import { + commitComposedPhase, + composedPhasePayload, + composedPhaseReached, + type ComposedPhaseContext, +} from "./phase.js"; + +export const COMPOSED_RUNNING_RECEIPT_VERSION = "simfile.composed-running.v1" as const; +export const COMPOSED_WORLD_TERMINAL_VERSION = "simfile.composed-world-terminal.v1" as const; + +const runningSchema = z.object({ + activation_receipt_digest: composedDigestSchema, + first_tick_receipt_digest: composedDigestSchema, + receipt_digest: composedDigestSchema, + run_id: composedRunIdSchema, + state: z.literal("running"), + version: z.literal(COMPOSED_RUNNING_RECEIPT_VERSION), +}).strict(); +const terminalSchema = z.object({ + outcome_digest: composedDigestSchema, + receipt_digest: composedDigestSchema, + reason: z.enum(["completed", "interrupted"]), + run_id: composedRunIdSchema, + running_receipt_digest: composedDigestSchema, + state: z.literal("terminal"), + terminal_tick: z.number().int().min(1).max(1_000_000_000), + version: z.literal(COMPOSED_WORLD_TERMINAL_VERSION), +}).strict(); + +export type ComposedRunningReceipt = z.infer; +export type ComposedWorldTerminalReceipt = z.infer; + +export const parseComposedRunningReceipt = (raw: unknown): ComposedRunningReceipt => + parseComposedDigestedContract(raw, runningSchema, + COMPOSED_RUNNING_RECEIPT_VERSION, "composed running receipt"); +export const createComposedRunningReceipt = ( + fields: Omit, +): ComposedRunningReceipt => parseComposedRunningReceipt(sealComposedContract( + COMPOSED_RUNNING_RECEIPT_VERSION, + { ...fields, state: "running", version: COMPOSED_RUNNING_RECEIPT_VERSION }, +)); +export const parseComposedWorldTerminalReceipt = ( + raw: unknown, +): ComposedWorldTerminalReceipt => parseComposedDigestedContract( + raw, terminalSchema, COMPOSED_WORLD_TERMINAL_VERSION, "composed world terminal receipt", +); +export const createComposedWorldTerminalReceipt = ( + fields: Omit, +): ComposedWorldTerminalReceipt => parseComposedWorldTerminalReceipt(sealComposedContract( + COMPOSED_WORLD_TERMINAL_VERSION, + { ...fields, state: "terminal", version: COMPOSED_WORLD_TERMINAL_VERSION }, +)); + +export interface ComposedSupervisionPort { + waitForWorldTerminal(input: Readonly<{ + expected_terminal_tick: number; + running: ComposedRunningReceipt; + signal: AbortSignal; + }>): Promise; +} + +const defaultOperatorTimeoutMs = 900_000; + +const waitForTerminal = async (input: Readonly<{ + operation: Promise; + operator_timeout_ms: number; + signal: AbortSignal; +}>): Promise => { + if (!Number.isSafeInteger(input.operator_timeout_ms) + || input.operator_timeout_ms < 1 || input.operator_timeout_ms > 86_400_000) { + throw new TypeError("composed operator timeout is invalid"); + } + if (input.signal.aborted) throw input.signal.reason; + let timeout: ReturnType | undefined; + let onAbort: (() => void) | undefined; + try { + return await Promise.race([ + input.operation, + new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error( + "composed world supervision reached the operator timeout", + )), input.operator_timeout_ms); + }), + new Promise((_resolve, reject) => { + onAbort = () => reject(input.signal.reason); + input.signal.addEventListener("abort", onAbort, { once: true }); + }), + ]); + } finally { + if (timeout !== undefined) clearTimeout(timeout); + if (onAbort !== undefined) input.signal.removeEventListener("abort", onAbort); + } +}; + +/** Supervises world time only; participant traffic is outside this boundary. */ +export const superviseComposedWorld = async (input: Readonly<{ + context: ComposedPhaseContext; + expected_terminal_tick: number; + journal: unknown; + operator_timeout_ms?: number; + port: ComposedSupervisionPort; + signal?: AbortSignal; +}>): Promise => { + let journal = parseComposedPhaseJournal(input.journal); + if (!Number.isSafeInteger(input.expected_terminal_tick) || input.expected_terminal_tick < 1 + || input.expected_terminal_tick > 1_000_000_000) { + throw new TypeError("composed terminal tick is invalid"); + } + if (!composedPhaseReached(journal, "tick_1")) { + throw new TypeError("composed supervision requires tick 1"); + } + if (!composedPhaseReached(journal, "running")) { + const activationDigest = composedPhasePayload(journal, "activated").receipt_digest; + const firstTickDigest = composedPhasePayload(journal, "tick_1").receipt_digest; + if (typeof activationDigest !== "string" || typeof firstTickDigest !== "string") { + throw new TypeError("composed supervision prerequisites are invalid"); + } + const receipt = createComposedRunningReceipt({ + activation_receipt_digest: activationDigest, + first_tick_receipt_digest: firstTickDigest, + run_id: journal.request.run_id, + }); + journal = await commitComposedPhase(journal, "running", { + receipt, receipt_digest: receipt.receipt_digest, run_id: journal.request.run_id, + }, input.context); + } + const running = parseComposedRunningReceipt( + composedPhasePayload(journal, "running").receipt, + ); + if (!composedPhaseReached(journal, "terminal")) { + const signal = input.signal ?? new AbortController().signal; + const terminal = parseComposedWorldTerminalReceipt(await waitForTerminal({ + operation: input.port.waitForWorldTerminal({ + expected_terminal_tick: input.expected_terminal_tick, running, signal, + }), + operator_timeout_ms: input.operator_timeout_ms ?? defaultOperatorTimeoutMs, + signal, + })); + if (terminal.run_id !== journal.request.run_id + || terminal.running_receipt_digest !== running.receipt_digest + || terminal.terminal_tick !== input.expected_terminal_tick) { + throw new TypeError("composed terminal correlation is invalid"); + } + if (terminal.reason !== "completed") { + throw new TypeError("composed world terminated without completing"); + } + journal = await commitComposedPhase(journal, "terminal", { + receipt: terminal, receipt_digest: terminal.receipt_digest, run_id: journal.request.run_id, + }, input.context); + } + return journal; +}; diff --git a/src/compose/types.ts b/src/compose/types.ts new file mode 100644 index 0000000..1296f0e --- /dev/null +++ b/src/compose/types.ts @@ -0,0 +1,36 @@ +export const COMPOSED_RUN_PHASES = Object.freeze([ + "requested", + "prepared", + "world_created", + "world_started_paused", + "world_ready", + "organization_started", + "organization_ready", + "topology_verified", + "activated", + "tick_1", + "running", + "terminal", + "world_paused", + "world_evidence_exported", + "organization_evidence_exported", + "cleaned", + "completed", +] as const); + +export type ComposedRunPhase = typeof COMPOSED_RUN_PHASES[number]; + +export const composedRunPhaseIndex = (phase: ComposedRunPhase): number => + COMPOSED_RUN_PHASES.indexOf(phase); + +export const nextComposedRunPhase = ( + phase: ComposedRunPhase, +): ComposedRunPhase | null => COMPOSED_RUN_PHASES[composedRunPhaseIndex(phase) + 1] ?? null; + +export interface ComposedRunClock { + now(): string; +} + +export interface ComposedRunFaultInjector { + afterPhase?(phase: ComposedRunPhase): void | Promise; +} diff --git a/src/compose/viewer.test.ts b/src/compose/viewer.test.ts new file mode 100644 index 0000000..c9275d5 --- /dev/null +++ b/src/compose/viewer.test.ts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { createComposedLiveViewerProjection } from "./liveViewerProjection.js"; +import { createComposedRunRecord } from "./runRecord.js"; +import type { ComposedViewerBinding } from "./viewerBinding.js"; +import { attachComposedViewer } from "./viewer.js"; +import { digestComposedJson } from "./json.js"; + +const liveBinding: ComposedViewerBinding = { + extensions: [{ id: "fixture-renderer", recorded_artifact: "presentation/final.json" }], + live_trace: { + artifact: { id: "viewer_trace", max_bytes: 4_096, + media_type: "application/json", path: "/tmp/spawnfile-public/viewer.json" }, + extension_id: "fixture-renderer", + }, + version: "simfile.composed-viewer-binding.v1", +}; + +describe("composed viewer attachment", () => { + it("returns one URL plus bounded observer seal and close handles", async () => { + let closed = false; + const result = await attachComposedViewer({ + dependencies: { + createServer: async () => ({ + awaitSeal: () => Promise.resolve({ identities: [], status: "recorded" }), + close: async () => { closed = true; }, url: "http://127.0.0.1:45123", + }), + loadExtensionPlan: async () => ({ identities: [], mounts: [] }), + }, + run_dir: "/run/record", trusted_project_root: "/project", + }); + assert.equal(result.state, "attached"); + if (result.state !== "attached") return; + assert.deepEqual(Object.keys(result).sort(), ["awaitSeal", "close", "state", "url"]); + assert.equal((await result.awaitSeal()).status, "recorded"); + assert.equal(result.url, "http://127.0.0.1:45123"); + await result.close(); + assert.equal(closed, true); + }); + + it("turns extension and renderer failures into non-mechanical evidence", async () => { + for (const stage of ["extension", "server"] as const) { + let serverCalls = 0; + const result = await attachComposedViewer({ + dependencies: { + createServer: async () => { + serverCalls += 1; + throw new Error("renderer failed"); + }, + loadExtensionPlan: async () => { + if (stage === "extension") throw new Error("extension digest mismatch"); + return { identities: [], mounts: [] }; + }, + }, + run_dir: "/run/record", trusted_project_root: "/project", + }); + assert.equal(result.state, "unavailable"); + assert.equal(serverCalls, stage === "extension" ? 0 : 1); + if (result.state === "unavailable") assert.match(result.error, /failed|mismatch/u); + } + }); + + it("follows the reserved output path into the composed staging projection", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-composed-viewer-")); + const project = path.join(root, "project"); + const outDir = path.join(root, "run"); + await mkdir(project); + const record = await createComposedRunRecord({ + identity: { + contract_versions: {}, created_at: new Date().toISOString(), run_id: "live-viewer-run", + }, + out_dir: outDir, + }); + let attachment; + let streamAbort: AbortController | undefined; + try { + attachment = await attachComposedViewer({ + run_dir: record.out_dir, + trusted_project_root: project, + }); + assert.equal(attachment.state, "attached"); + if (attachment.state !== "attached") return; + const projection = createComposedLiveViewerProjection({ + binding: liveBinding, run_id: "live-viewer-run", staging_dir: record.staging_dir, + }); + const bytes = Buffer.from(`${JSON.stringify({ + agents: [], corridors: [], ledger_facts: [], presence: [], + rooms: [{ id: "floor", kind: "square", label: "floor", members: [], + scale: [10, 6], scene: [0, 0, 0], scope: "world://floor" }], + run_id: "live-viewer-run", run_name: "live viewer", signals: [], + spatial_samples: [{ occupancy: {}, objects: [{ id: "ball", position: [1, 2], + velocity: [0, 0] }], tick: 3, transit: [] }], + version: "viewer.trace.v1", + })}\n`); + const request = { + artifact: liveBinding.live_trace!.artifact, + descriptor_digest: `sha256:${"d".repeat(64)}`, + run_id: "live-viewer-run", + selected_target: { fingerprint: `sha256:${"1".repeat(32)}`, + handle: "opaque_1111111111111111" }, + version: "spawnfile.target-public-artifact-snapshot.request.v1", + world_service_handle: "opaque_2222222222222222", + }; + const publish = (snapshot: Uint8Array) => projection.publish(snapshot, { + artifact_id: "viewer_trace", + content_digest: `sha256:${createHash("sha256").update(snapshot).digest("hex")}`, + media_type: "application/json", + request, + request_digest: digestComposedJson( + "spawnfile.target-public-artifact-snapshot.request.v1", request, + ), + response_version: "spawnfile.target-public-artifact-snapshot.v1", + run_id: "live-viewer-run", + size_bytes: snapshot.byteLength, + }); + await publish(bytes); + const state = await (await fetch(`${attachment.url}/api/state`)).json() as { + mode: string; + }; + const world = await (await fetch(`${attachment.url}/api/world`)).json() as { + trace: { spatial_samples: unknown[]; viewer_extension_data?: Record }; + }; + assert.equal(state.mode, "run-live"); + assert.equal(world.trace.spatial_samples.length, 1); + assert.ok(world.trace.viewer_extension_data?.["fixture-renderer"]); + + streamAbort = new AbortController(); + const response = await fetch(`${attachment.url}/api/run-frames`, { + signal: streamAbort.signal, + }); + const reader = response.body!.pipeThrough(new TextDecoderStream()).getReader(); + let buffered = ""; + const readChunk = (): Promise> => + new Promise((resolve, reject) => { + const timeout = setTimeout(() => reject( + new Error("viewer extension stream timed out")), 2_000); + void reader.read().then(resolve, reject).finally(() => clearTimeout(timeout)); + }); + const nextExtension = async (): Promise> => { + for (;;) { + const boundary = buffered.indexOf("\n\n"); + if (boundary >= 0) { + const block = buffered.slice(0, boundary); + buffered = buffered.slice(boundary + 2); + const data = block.split("\n").find((line) => line.startsWith("data: ")); + if (data !== undefined) { + const payload = JSON.parse(data.slice(6)) as Record; + if (payload.type === "viewer-extension-data") return payload; + } + continue; + } + const chunk = await readChunk(); + if (chunk.done) throw new Error("viewer frame stream ended before extension data"); + buffered += chunk.value; + } + }; + await nextExtension(); + const terminalRaw = JSON.parse(bytes.toString("utf8")) as Record; + terminalRaw.playback_status = "completed"; + await publish(Buffer.from(`${JSON.stringify(terminalRaw)}\n`)); + const terminalEvent = await nextExtension() as { + extensionData?: { "fixture-renderer"?: { playback_status?: string } }; + }; + assert.equal(terminalEvent.extensionData?.["fixture-renderer"]?.playback_status, + "completed"); + } finally { + streamAbort?.abort(); + if (attachment?.state === "attached") await attachment.close(); + await record.abort(); + await rm(root, { force: true, recursive: true }); + } + }); +}); diff --git a/src/compose/viewer.ts b/src/compose/viewer.ts new file mode 100644 index 0000000..e40f2e2 --- /dev/null +++ b/src/compose/viewer.ts @@ -0,0 +1,57 @@ +import { createViewerServer, type ViewerServerHandle } from "../view/server.js"; +import type { RunSealFollowerState } from "../view/runSealFollower.js"; +import { loadRunViewerExtensionPlan } from "../view/runViewerExtensions.js"; + +export type ComposedViewerAttachment = Readonly< + | { awaitSeal: () => Promise; + close: () => Promise; state: "attached"; url: string } + | { error: string; state: "unavailable" } +>; + +export interface ComposedViewerDependencies { + readonly createServer?: typeof createViewerServer; + readonly loadExtensionPlan?: typeof loadRunViewerExtensionPlan; +} + +const message = (error: unknown): string => + error instanceof Error ? error.message : String(error); + +/** + * Attaches one observer to a run-output path. Failure is returned as evidence; + * it never enters the composed lifecycle or world-service control ports. + */ +export const attachComposedViewer = async (input: Readonly<{ + run_dir: string; + trusted_project_root: string; + dependencies?: ComposedViewerDependencies; +}>): Promise => { + const loadExtensionPlan = input.dependencies?.loadExtensionPlan + ?? loadRunViewerExtensionPlan; + const createServer = input.dependencies?.createServer ?? createViewerServer; + let handle: ViewerServerHandle | undefined; + try { + const plan = await loadExtensionPlan({ + explicitDescriptors: [], ignoreRecorded: false, + runDir: input.run_dir, trustedRoot: input.trusted_project_root, + }); + handle = await createServer({ + extensionIdentities: plan.identities, + extensions: plan.mounts, + mode: "replay", + port: 0, + reconcileViewerExtensionsAtSeal: plan.reconcileAtSeal, + sourcePath: input.run_dir, + }); + return Object.freeze({ + awaitSeal: () => handle!.awaitSeal(), + close: async () => { try { await handle?.close(); } catch { /* observer only */ } }, + state: "attached" as const, + url: handle.url, + }); + } catch (error) { + if (handle !== undefined) { + try { await handle.close(); } catch { /* observer only */ } + } + return Object.freeze({ error: message(error), state: "unavailable" as const }); + } +}; diff --git a/src/compose/viewerBinding.test.ts b/src/compose/viewerBinding.test.ts new file mode 100644 index 0000000..0da1eeb --- /dev/null +++ b/src/compose/viewerBinding.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { parseComposedViewerBinding } from "./viewerBinding.js"; + +const artifacts = [ + { path: "presentation/world.json", role: "presentation" as const }, + { path: "probe/final.json", role: "probe" as const }, +]; +const binding = () => ({ + extensions: [{ + id: "fixture-renderer", + recorded_artifact: "presentation/world.json", + }], + live_trace: { + artifact: { + id: "viewer_trace", + max_bytes: 120_000, + media_type: "application/json", + path: "/tmp/spawnfile-public/viewer-trace.json", + }, + extension_id: "fixture-renderer", + }, + version: "simfile.composed-viewer-binding.v1", +}); + +describe("composed viewer binding", () => { + it("binds one live public trace to one recorded presentation artifact", () => { + assert.deepEqual(parseComposedViewerBinding(binding(), artifacts), binding()); + assert.equal(parseComposedViewerBinding(undefined, artifacts), undefined); + }); + + it("rejects missing, wrong-role, duplicate, and unknown extension bindings", () => { + for (const value of [ + { ...binding(), extensions: [{ id: "fixture-renderer", + recorded_artifact: "missing.json" }] }, + { ...binding(), extensions: [{ id: "fixture-renderer", + recorded_artifact: "probe/final.json" }] }, + { ...binding(), extensions: [binding().extensions[0], binding().extensions[0]] }, + { ...binding(), live_trace: { ...binding().live_trace, + extension_id: "other-renderer" } }, + ]) assert.throws( + () => parseComposedViewerBinding(value, artifacts), + /composed viewer binding is invalid|Invalid input/u, + ); + }); + + it("rejects path escapes and malformed public artifact declarations", () => { + for (const value of [ + { ...binding(), extensions: [{ id: "fixture-renderer", + recorded_artifact: "../world.json" }] }, + { ...binding(), live_trace: { ...binding().live_trace, + artifact: { ...binding().live_trace.artifact, path: "/tmp/private/view.json" } } }, + { ...binding(), live_trace: { ...binding().live_trace, + artifact: { ...binding().live_trace.artifact, + path: "/tmp/spawnfile-public/../private.json" } } }, + { ...binding(), live_trace: { ...binding().live_trace, + artifact: { ...binding().live_trace.artifact, + path: `/tmp/spawnfile-public/${"a".repeat(240)}.json` } } }, + { ...binding(), live_trace: { ...binding().live_trace, + artifact: { ...binding().live_trace.artifact, max_bytes: 131_073 } } }, + { ...binding(), live_trace: { ...binding().live_trace, + artifact: { ...binding().live_trace.artifact, media_type: "text/plain" } } }, + ]) assert.throws(() => parseComposedViewerBinding(value, artifacts)); + }); +}); diff --git a/src/compose/viewerBinding.ts b/src/compose/viewerBinding.ts new file mode 100644 index 0000000..7056a66 --- /dev/null +++ b/src/compose/viewerBinding.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +import type { ComposedArtifactRole } from "./runRecord.js"; + +export const COMPOSED_VIEWER_BINDING_VERSION = + "simfile.composed-viewer-binding.v1" as const; + +const extensionId = z.string().regex(/^[a-z][a-z0-9-]{0,63}$/u); +const artifactId = z.string().regex(/^[a-z][a-z0-9_-]{0,63}$/u); +const relativePath = z.string().max(4_096).refine((value) => + value.length > 0 && !value.startsWith("/") && !value.includes("\\") + && value.split("/").every((part) => part.length > 0 && part !== "." && part !== "..")); +const publicPath = z.string().max(255).regex( + /^\/tmp\/spawnfile-public\/[A-Za-z0-9][A-Za-z0-9._/-]*$/u, +).refine((value) => !value.includes("//") + && value.slice("/tmp/spawnfile-public/".length).split("/") + .every((part) => part.length > 0 && part !== "." && part !== "..")); +const extension = z.object({ + id: extensionId, + recorded_artifact: relativePath, +}).strict(); +const liveTrace = z.object({ + artifact: z.object({ + id: artifactId, + max_bytes: z.number().int().min(1).max(131_072), + media_type: z.literal("application/json"), + path: publicPath, + }).strict(), + extension_id: extensionId, +}).strict(); +const viewerBinding = z.object({ + extensions: z.array(extension).min(1).max(32), + live_trace: liveTrace.optional(), + version: z.literal(COMPOSED_VIEWER_BINDING_VERSION), +}).strict(); + +type ParsedViewerBinding = z.infer; +export interface ComposedViewerBinding { + readonly extensions: readonly Readonly[]; + readonly live_trace?: Readonly<{ + artifact: Readonly["artifact"]>; + extension_id: string; + }>; + readonly version: typeof COMPOSED_VIEWER_BINDING_VERSION; +} + +/** Validates the optional host-only viewer projection declaration. */ +export const parseComposedViewerBinding = ( + raw: unknown, + artifacts: readonly Readonly<{ path: string; role: ComposedArtifactRole }>[], +): ComposedViewerBinding | undefined => { + if (raw === undefined) return undefined; + const parsed = viewerBinding.parse(raw); + const ids = new Set(parsed.extensions.map(({ id }) => id)); + const paths = new Set(); + if (ids.size !== parsed.extensions.length + || parsed.extensions.some(({ recorded_artifact: artifactPath }) => { + if (paths.has(artifactPath)) return true; + paths.add(artifactPath); + return !artifacts.some(({ path, role }) => + path === artifactPath && role === "presentation"); + }) + || (parsed.live_trace !== undefined + && !ids.has(parsed.live_trace.extension_id))) { + throw new TypeError("composed viewer binding is invalid"); + } + return Object.freeze({ + ...parsed, + extensions: Object.freeze(parsed.extensions.map((entry) => Object.freeze(entry))), + ...(parsed.live_trace === undefined ? {} : { + live_trace: Object.freeze({ + ...parsed.live_trace, + artifact: Object.freeze(parsed.live_trace.artifact), + }), + }), + }); +}; diff --git a/src/dynamics/AGENTS.md b/src/dynamics/AGENTS.md new file mode 100644 index 0000000..c125d26 --- /dev/null +++ b/src/dynamics/AGENTS.md @@ -0,0 +1,159 @@ +# Dynamics Provider Boundary + +This folder owns the trusted local-module mechanics seam. It is generic: sport, +physics, economic, and other domain behavior belongs in project provider files, +not in Simfile schema keys. + +## Files + +- `types.ts` defines the public provider, action, observation, event, provenance, + provider integration metadata, and provider-local snapshot contracts. +- `limits.ts` publishes the fixed v1 resource ceilings enforced at the boundary. +- `buildInput.ts` publishes the frozen, host-owned B11 TypeScript and esbuild + preparation contract; B12 consumes it to prepare `.ts` and `.mjs` providers. +- `buildStaticPolicy.ts` publishes B54 static path, source, metafile, and output policy checks. +- `buildStaticCommonJsPolicy.ts` publishes the structural CommonJS detector used by emitted ESM policy checks. +- `buildStaticResolverPolicy.ts` publishes static source, separate runtime/declaration + package resolution, and emitted resolver AST helpers. +- `buildPackagePolicy.ts` owns package identity plus the declaration-to-runtime + ownership mapping used to avoid typechecking bundled vendor JavaScript when + authoritative declarations exist. +- `buildSourceSnapshot.ts` retains first-observed preparation bytes so compiler, + bundler, and closure hashing cannot silently consume different file contents. +- `buildRuntimeTypes.test.ts` proves runtime package bytes and separate + declaration-package evidence are both sealed. +- `buildStaticCompilerHostPolicy.ts` publishes the lexical TypeScript resolution and compiler-read guard. +- `buildStaticGraphPolicy.ts` publishes immutable runtime-only graph preflight and exact metafile comparison. +- `buildStaticCompilerHostPolicy.test.ts` proves compiler lexical preflight/checked read invariants and delegate isolation. +- `buildStaticPolicy.test.ts` proves B54 static policy primitives without authored evaluation. +- `buildDeterminism.test.ts` proves complete closure/byte determinism under locale and isolated axis mutations. +- `buildHostile.test.ts` proves hostile and acceptance end-to-end boundaries without executing authored artifacts. +- `buildReceiptHostile.test.ts` publishes B13 hostile receipt boundary proofs for cross-root/locale stability, stale authority failures, ambiguity propagation, and hostile lock metadata omission. +- `buildReceiptCreation.test.ts` proves successful receipt construction and + canonical closure evidence; `buildReceipt.test.ts` retains hostile receipt + input and validation cases. +- `buildLoad.ts` owns the one-provider-per-run scratch lifecycle: verified + content-address publication, receipt/source revalidation, artifact-only + import, evidence handoff, and teardown cleanup. +- `buildLoadFiles.ts` owns no-clobber filesystem publication and exact + per-path cleanup ownership for scratch and evidence pairs. +- `canonicalJson.ts` normalizes and canonically serializes bounded safe JSON. +- `load.ts` loads an explicitly declared provider and binds configuration, + clock, seed, and provenance into a checked `DynamicsSession`. +- `loadCore.ts` owns the shared sealed provider/source build lifecycle; + `loadRunActionSource.ts` is the internal run-driver loader for the optional + named `createDynamicsRunActionSource` export. +- `runActionSource.ts` defines the genre-neutral, type-only public contract for + a scripted/non-live tick notification source from the same sealed artifact. +- `modulePath.ts` resolves only regular, non-symlink, portable project-relative + `.ts` or `.mjs` entry points and hashes their bytes. +- `session.ts` owns canonical action ordering, principal-scoped idempotency, + fixed synchronous stepping, immutable integration metadata, exact sense + grants, rollback, and snapshot state. +- `sessionContract.ts`, `sessionIssuance.ts`, and `sessionProviderBoundary.ts` + keep the public facade, unforgeable authority issuance, and synchronous + provider rollback checks separate from session state transitions. +- `retainedCapacity.ts` owns the issued, exact error identity for permanent + retained action-ingress capacity frontiers. +- `sameDynamicsSessionSnapshot.ts` compares already issued session snapshots + for world purity checks without the generic hostile-JSON ceiling. +- `validation.ts` checks provider metadata, output, and public wire values. +- `snapshotValidation.ts` validates provider-local checkpoint invariants. +- `testSupport.test-helper.ts` is test fixture support excluded from production emit. +- `index.ts` is the public barrel. + +## Constraints + +- Prepared providers are trusted bundled Node code, not sandboxed plugins. + Build path and closure checks prevent accidental source escape; they do not + restrict allowlisted built-ins, environment access, networking, or clocks. +- Effective seed, clock scaling, and canonical config are validated before the + host resolves or evaluates provider code; invalid initialization fails closed. +- `module_sha256` covers the exact executed bundle. A loader nonce re-evaluates + that content-addressed artifact for every session; the prepared bundle seals + project and package code while allowlisted `node:` built-ins remain runtime + externals. +- B11 defines only the authored entry-path and fixed build-input contract. B12 + owns in-memory preparation; B14 owns artifact persistence and verified loading. + Do not add bundling or typecheck execution to `buildInput.ts`. +- B13 owns hostile proof coverage for post-prepare authority failures, receipt + stability, ambiguity propagation, and hostile lock-metadata omission. +- Only the host assigns action/event order, tick, identity, and mechanical + provenance. Providers decide mechanics but cannot replace host-stamped fields. +- A run action source receives canonical frozen initialization and a + tick-scoped host port only. It must never receive the provider, session, + filesystem, transport, credentials, clocks, models, or fixture-specific + vocabulary through this generic contract. +- `queueAction` changes host ingress state only. Provider state may change only + inside synchronous `step`, `initialize`, or `restore`; `observe` and + `snapshot` are pure contracts. +- Sense access is an exact host-resolved address grant. Providers never receive + caller identity and cannot widen the supplied grant list. +- A `DynamicsSessionSnapshot` is not a whole-world checkpoint. A composed + driver must also preserve Simfile variables, rule state, presence, ledger + cursors, and any other subsystem state. +- All provider inputs and outputs are bounded by `DYNAMICS_LIMITS`; exceeding a + depth, size, history, action, sense, event, or cause fuse fails closed. V1 + allows 24 JSON levels, 4,096 nodes, 16,384 UTF-16 code units per string, + 65,536 cumulative key/string code units per JSON value, 256 per identifier, + 4,096 per result message, 10,000 retained ingress records, and 1,048,576 + canonical serialized code units across retained ingress. The exported limits + object is normative for the remaining per-tick and observation ceilings. +- Keep schema and runtime genre-neutral, files below 400 lines, named exports, + and tests beside the code they cover. + +## Action retention policy + +Action evidence has three retention tiers: + +1. Durable evidence keeps every complete attempt, receipt, result, and causal + event. The run writer streams these values to `raw/action-attempts.jsonl`, + `raw/action-results.jsonl`, `raw/world/causal.jsonl`, and the canonical + ledger. Each append is synchronized before the session acknowledges that + attempt. These artifacts are the source for evidence and replay. +2. Live host state keeps complete inputs only in the current tick's pending + queue. Its idempotency index keeps `principal_id`, `act_id`, `at_tick`, the + issued receipt, and a 64-code-unit SHA-256 digest of the canonical attempt. + Accepted and resolved sequence sets use a contiguous floor plus bounded + entries above that floor. Complete evidence awaiting a durable + acknowledgment is bounded to the same one-tick admission frontier and is + not checkpoint state. +3. After durable acknowledgment, a complete input that is no longer pending + is released. Idempotency entries are released when `step()` advances beyond + their admission tick. The complete value remains in the durable artifacts + named above. + +The necessary idempotency window is one host tick, with no additional retry +grace. `queueAction` can queue an attempt only when `at_tick === nextTick`. +After `step()` advances, that same attempt can only receive `wrong_tick`, so an +older queued receipt would no longer be a truthful current answer. A retry +after eviction therefore receives a new `wrong_tick` receipt instead of the +earlier queued receipt. + +The new-identity bound for that window is +`DYNAMICS_LIMITS.actions_per_tick`, and it applies to queued identities and +temporal rejections alike. This explicitly caps the previously uncapped +rejection path. The canonical compact-record maximum is derived in +`limits.ts`: an identifier can occupy `2 + 256 * 6 = 1,538` serialized code +units after JSON escaping, a digest occupies `2 + 64 = 66`, and a safe integer +occupies at most 16. The larger queued receipt shape is +`129 + 3 * (1,538 - 2) + (66 - 2) + 3 * (16 - 1) = 4,846` code units. Therefore +the ingress ceiling is `128 * 4,846 = 620,288` code units. The established +`retained_action_records` and `retained_action_code_units` values remain +unchanged as hostile-input backstops for other host stores. + +For a probe with 200 concurrent submitters producing large structured attempts +each tick, only 128 can enter the contract window. The 129th submission raises +the issued retained-capacity error and the run fails closed; later submissions +are not silently discarded. Complete pending inputs remain subject to the +existing per-value JSON bounds, while compact retained cost is independent of +their size. + +For a probe with two submitters acting only once every few thousand ticks, the +index is empty between active ticks. Both entries remain available throughout +their admission tick and are released after advancement. The bound is chosen +in records rather than elapsed ticks, so sparse submissions receive the same +truthful current-tick retry behavior without reserving history during silence. + +Persistent stream volume is unchanged and continues to grow with durable +evidence; this policy bounds live host retention only. diff --git a/src/dynamics/CLAUDE.md b/src/dynamics/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/dynamics/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/dynamics/actionRetention.test.ts b/src/dynamics/actionRetention.test.ts new file mode 100644 index 0000000..f2968de --- /dev/null +++ b/src/dynamics/actionRetention.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { loadDynamicsSession } from "./load.js"; +import { DYNAMICS_ACTION_RETENTION_LIMITS } from "./limits.js"; +import { + counterAction, + createDynamicsTestProject, + removeDynamicsTestProject +} from "./testSupport.test-helper.js"; + +describe("DynamicsSession retained action invariants", () => { + it("keeps a prior-tick identity window without emitting replay evidence", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + const first = counterAction({ act_id: "cross-tick", input: { amount: 1 } }); + assert.deepEqual(session.queueAction(first), { act_id: "cross-tick", apply_tick: 0, queued: true, sequence: 1 }); + session.step(); + const afterStep = session.snapshot(); + assert.equal(afterStep.action_ingress.length, 1); + assert.equal(afterStep.action_ingress[0]?.retained_at_tick, 0); + assert.equal(afterStep.action_ingress[0]?.receipt.code, "wrong_tick"); + assert.deepEqual(session.queueAction({ ...first, at_tick: 1, input: { amount: 2 } }), { + act_id: "cross-tick", apply_tick: 1, code: "act_id_conflict", queued: false + }); + assert.deepEqual(session.snapshot(), afterStep); + assert.deepEqual(session.readActionIngressEvidence(1), []); + } finally { await removeDynamicsTestProject(project); } + }); + + it("evicts the canonical oldest identity when the bounded window is exceeded", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + for (let index = 0; index < DYNAMICS_ACTION_RETENTION_LIMITS.records + 1; index += 1) { + session.queueAction(counterAction({ act_id: `window-${index}` })); + session.step(); + } + assert.equal(session.snapshot().action_ingress.length, DYNAMICS_ACTION_RETENTION_LIMITS.records); + assert.equal(session.queueAction(counterAction({ act_id: "window-0", at_tick: session.nextTick })).queued, true); + } finally { await removeDynamicsTestProject(project); } + }); + + it("round-trips a populated cross-tick window byte-for-byte", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + session.queueAction(counterAction({ act_id: "round-trip" })); + session.step(); + const snapshot = session.snapshot(); + session.restore(snapshot); + assert.deepEqual(session.snapshot(), snapshot); + } finally { await removeDynamicsTestProject(project); } + }); + + it("detects a digest conflict for a deep nested input-only change", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + session.queueAction(counterAction({ + input: { nested: { entries: [{ value: "first" }] } } + })); + assert.deepEqual(session.queueAction(counterAction({ + input: { nested: { entries: [{ value: "second" }] } } + })), { + act_id: "act-1", + apply_tick: 0, + code: "act_id_conflict", + queued: false + }); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("rejects inconsistent watermarks and retained receipt times", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + session.queueAction(counterAction()); + session.step(); + const resolved = session.snapshot(); + const futureResolved = structuredClone(resolved); + futureResolved.resolved_action_sequences.floor += 1; + assert.throws(() => session.restore(futureResolved), /resolved sequence watermark/u); + + const fresh = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(fresh); + fresh.queueAction(counterAction({ act_id: "future", at_tick: 2 })); + const rejected = fresh.snapshot(); + const rejectedRecord = rejected.action_ingress[0]; + if (rejectedRecord) rejectedRecord.receipt.apply_tick = 1; + assert.throws(() => fresh.restore(rejected), /belong to next_tick/u); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("requires retained queued sequences to be contiguous from the persisted floor", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + session.queueAction(counterAction({ act_id: "z-first" })); + session.step(); + session.queueAction(counterAction({ act_id: "a-second", at_tick: 1 })); + const snapshot = session.snapshot(); + assert.deepEqual( + snapshot.action_ingress.filter((record) => record.receipt.queued) + .map((record) => [record.receipt.sequence, record.at_tick]), + [[2, 1]] + ); + + const inverted = structuredClone(snapshot); + inverted.action_ingress_floor = 1; + assert.throws( + () => session.restore(inverted), + /contiguous from the retained floor/u + ); + assert.doesNotThrow(() => session.restore(snapshot)); + } finally { + await removeDynamicsTestProject(project); + } + }); +}); diff --git a/src/dynamics/actionRetention.ts b/src/dynamics/actionRetention.ts new file mode 100644 index 0000000..2d5fe34 --- /dev/null +++ b/src/dynamics/actionRetention.ts @@ -0,0 +1,119 @@ +import { createHash } from "node:crypto"; + +import { canonicalDynamicsJson } from "./canonicalJson.js"; +import { DYNAMICS_ACTION_RETENTION_LIMITS } from "./limits.js"; +import { issueDynamicsRetainedActionCapacityError } from "./retainedCapacity.js"; +import { cloneAttempt, cloneReceipt } from "./sessionValues.js"; +import type { + DynamicsActionAttempt, + DynamicsActionIdempotencyRecord, + DynamicsActionIngressEvidence, + DynamicsActionIngressRecord, + DynamicsActionQueueReceipt, + ReadonlyDynamicsJsonObject +} from "./types.js"; + +export const dynamicsActionKey = ( + value: Pick +): string => canonicalDynamicsJson([value.principal_id, value.act_id]); + +export const digestDynamicsActionAttempt = ( + attempt: Omit & { + readonly input: ReadonlyDynamicsJsonObject; + } +): string => createHash("sha256") + .update(canonicalDynamicsJson(attempt, "dynamics action attempt")) + .digest("hex"); + +export const createDynamicsActionIdempotencyRecord = ( + attempt: DynamicsActionAttempt, + receipt: DynamicsActionQueueReceipt +): DynamicsActionIdempotencyRecord => ({ + act_id: attempt.act_id, + at_tick: attempt.at_tick, + attempt_sha256: digestDynamicsActionAttempt(attempt), + principal_id: attempt.principal_id, + retained_at_tick: receipt.apply_tick, + receipt: cloneReceipt(receipt) +}); + +export const cloneDynamicsActionIdempotencyRecord = ( + record: DynamicsActionIdempotencyRecord +): DynamicsActionIdempotencyRecord => ({ + ...record, + receipt: cloneReceipt(record.receipt) +}); + +export const cloneDynamicsActionIngressEvidence = ( + evidence: DynamicsActionIngressEvidence +): DynamicsActionIngressEvidence => ({ + ordinal: evidence.ordinal, + record: { + attempt: cloneAttempt(evidence.record.attempt), + receipt: cloneReceipt(evidence.record.receipt) + } +}); + +export const dynamicsActionIdempotencyRecordCodeUnits = ( + record: DynamicsActionIdempotencyRecord +): number => canonicalDynamicsJson( + record, + "dynamics action idempotency record" +).length; + +export const sameDynamicsActionAttempt = ( + record: DynamicsActionIdempotencyRecord, + attempt: DynamicsActionAttempt +): boolean => record.attempt_sha256 === digestDynamicsActionAttempt(attempt); + +export interface DynamicsActionIngressEvidenceBuffer { + readonly ordinal: number; + acknowledge(throughOrdinal: number): void; + assertAvailable(): void; + emit(attempt: DynamicsActionAttempt, receipt: DynamicsActionQueueReceipt): void; + read(afterOrdinal: number): readonly DynamicsActionIngressEvidence[]; + restore(ordinal: number): void; +} + +export const createDynamicsActionIngressEvidenceBuffer = (): DynamicsActionIngressEvidenceBuffer => { + let entries: DynamicsActionIngressEvidence[] = []; + let ordinal = 0; + let nextOrdinal = 1; + const assertAvailable = (): void => { + if (entries.length >= DYNAMICS_ACTION_RETENTION_LIMITS.records) { + throw issueDynamicsRetainedActionCapacityError("records"); + } + if (nextOrdinal >= Number.MAX_SAFE_INTEGER) { + throw issueDynamicsRetainedActionCapacityError("sequence"); + } + }; + return { + get ordinal() { return ordinal; }, + acknowledge: (throughOrdinal): void => { + if (!Number.isSafeInteger(throughOrdinal) || throughOrdinal < 0 || throughOrdinal > ordinal) { + throw new Error("dynamics ingress evidence acknowledgment is outside the issued range"); + } + entries = entries.filter((entry) => entry.ordinal > throughOrdinal); + }, + assertAvailable, + emit: (attempt, receipt): void => { + const issued = nextOrdinal++; + ordinal = issued; + entries.push({ ordinal: issued, record: { + attempt: cloneAttempt(attempt), receipt: cloneReceipt(receipt) + } }); + }, + read: (afterOrdinal): readonly DynamicsActionIngressEvidence[] => { + if (!Number.isSafeInteger(afterOrdinal) || afterOrdinal < 0) { + throw new Error("dynamics ingress evidence ordinal must be a non-negative safe integer"); + } + return entries.filter((entry) => entry.ordinal > afterOrdinal) + .map(cloneDynamicsActionIngressEvidence); + }, + restore: (restoredOrdinal): void => { + entries = entries.filter((entry) => entry.ordinal <= restoredOrdinal); + ordinal = restoredOrdinal; + nextOrdinal = restoredOrdinal + 1; + } + }; +}; diff --git a/src/dynamics/build.test.ts b/src/dynamics/build.test.ts new file mode 100644 index 0000000..86813cf --- /dev/null +++ b/src/dynamics/build.test.ts @@ -0,0 +1,291 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import { describe, it } from "node:test"; + +import { prepareDynamicsBuild } from "./build.js"; +import { createDynamicsClosureIdentity } from "./buildIdentity.js"; +import { DYNAMICS_BUILD_CONTRACT, DYNAMICS_BUILD_PREPARATION_POLICY } from "./buildInput.js"; +import { isDeclarationFile, isTransformableTypeScriptSource } from "./buildTypecheck.js"; +import { + artifactText, + createBuildTestProject, + prepareBuild, + removeBuildTestPaths, + writeBuildFile +} from "./buildTestSupport.test-helper.js"; + +describe("dynamics build preparation", () => { + it("keeps the exact B11 literal deeply frozen", () => { + assert.deepEqual(DYNAMICS_BUILD_CONTRACT, { + allowedExtensions: [".ts", ".mjs"], + typescript: { strict: true, noEmit: true, target: "ES2022", module: "NodeNext", moduleResolution: "NodeNext" }, + esbuild: { platform: "node", format: "esm", target: "node22", bundle: true, sourcemap: false, legalComments: "none", charset: "utf8" } + }); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_CONTRACT), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_CONTRACT.allowedExtensions), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_CONTRACT.typescript), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_CONTRACT.esbuild), true); + assert.throws(() => (DYNAMICS_BUILD_CONTRACT.allowedExtensions as unknown as string[]).push(".js"), TypeError); + }); + + it("uses a separate, frozen preparation policy in closure identity", () => { + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.esbuild), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.esbuild.onLoadTranspile), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.package), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.source), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.source.declarationExtensions), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.source.checkedExtensions), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.source.javaScriptSyntaxExtensions), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.source.rejectRuntimeTypeExtensions), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.source.transformExtensions), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.simfileDynamics), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.simfileDynamics.acceptedErasedForms), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.simfileDynamics.runtimeCallForms), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.simfileDynamics.runtimeExpressionUnwrap), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.suppression), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.suppression.commentKinds), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.suppression.fullLineDirectives), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.nodeBuiltins), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.typescript), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.typescript.compilerOptions), true); + assert.equal(Object.isFrozen(DYNAMICS_BUILD_PREPARATION_POLICY.typescript.types), true); + assert.throws(() => (DYNAMICS_BUILD_PREPARATION_POLICY.nodeBuiltins as unknown as string[]).push("node:fs"), TypeError); + const base = { + buildContract: DYNAMICS_BUILD_CONTRACT, + entry: "./systems/provider.mjs", + esbuildVersion: "1", + inputs: [], + preparationPolicy: DYNAMICS_BUILD_PREPARATION_POLICY, + typecheckMode: "none" as const, + typescriptVersion: "1", + usedNodeBuiltins: [] + }; + assert.notEqual( + createDynamicsClosureIdentity(base).sha256, + createDynamicsClosureIdentity({ ...base, preparationPolicy: { ...DYNAMICS_BUILD_PREPARATION_POLICY, esbuild: { ignoreAnnotations: false } } }).sha256 + ); + assert.notEqual( + createDynamicsClosureIdentity(base).sha256, + createDynamicsClosureIdentity({ + ...base, + preparationPolicy: { + ...DYNAMICS_BUILD_PREPARATION_POLICY, + esbuild: { + ...DYNAMICS_BUILD_PREPARATION_POLICY.esbuild, + onLoadTranspile: { ...DYNAMICS_BUILD_PREPARATION_POLICY.esbuild.onLoadTranspile, target: "ES2019" as const } + }, + nodeBuiltins: [...DYNAMICS_BUILD_PREPARATION_POLICY.nodeBuiltins, "node:fs"] as const, + package: { ...DYNAMICS_BUILD_PREPARATION_POLICY.package, versionPattern: "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)$" as const }, + source: { + ...DYNAMICS_BUILD_PREPARATION_POLICY.source, + transformExtensions: [".ts", ".tsx", ".mts", ".cts"] as const, + rejectRuntimeTypeExtensions: [".ts"] as const + }, + suppression: { ...DYNAMICS_BUILD_PREPARATION_POLICY.suppression, fullLineDirectives: ["ignore", "expect-error", "nocheck"] as const }, + typescript: { ...DYNAMICS_BUILD_PREPARATION_POLICY.typescript, checkJs: false }, + simfileDynamics: { ...DYNAMICS_BUILD_PREPARATION_POLICY.simfileDynamics, runtimeResolutionFailure: "type-only surface only" } + } + }).sha256 + ); + for (const typescript of [ + { ...DYNAMICS_BUILD_PREPARATION_POLICY.typescript, typeRootPackage: "@types/other" as const }, + { ...DYNAMICS_BUILD_PREPARATION_POLICY.typescript, typeRootResolution: "other" as const }, + { ...DYNAMICS_BUILD_PREPARATION_POLICY.typescript, types: ["node", "other"] as const } + ]) { + assert.notEqual( + createDynamicsClosureIdentity(base).sha256, + createDynamicsClosureIdentity({ + ...base, + preparationPolicy: { ...DYNAMICS_BUILD_PREPARATION_POLICY, typescript } + }).sha256 + ); + } + for (const preparationPolicy of [ + { + ...DYNAMICS_BUILD_PREPARATION_POLICY, + package: { ...DYNAMICS_BUILD_PREPARATION_POLICY.package, nodeModulesDirectory: "vendor" } + }, + { + ...DYNAMICS_BUILD_PREPARATION_POLICY, + package: { ...DYNAMICS_BUILD_PREPARATION_POLICY.package, scopePrefix: "!" } + }, + { + ...DYNAMICS_BUILD_PREPARATION_POLICY, + source: { ...DYNAMICS_BUILD_PREPARATION_POLICY.source, javaScriptSyntaxExtensions: [".js"] } + }, + { + ...DYNAMICS_BUILD_PREPARATION_POLICY, + suppression: { ...DYNAMICS_BUILD_PREPARATION_POLICY.suppression, directivePrefix: "@check-" } + }, + { + ...DYNAMICS_BUILD_PREPARATION_POLICY, + suppression: { ...DYNAMICS_BUILD_PREPARATION_POLICY.suppression, placement: "anywhere" } + }, + { + ...DYNAMICS_BUILD_PREPARATION_POLICY, + simfileDynamics: { ...DYNAMICS_BUILD_PREPARATION_POLICY.simfileDynamics, acceptedErasedForms: ["import-type"] } + }, + { + ...DYNAMICS_BUILD_PREPARATION_POLICY, + simfileDynamics: { ...DYNAMICS_BUILD_PREPARATION_POLICY.simfileDynamics, runtimeCallForms: ["dynamic-import"] } + }, + { + ...DYNAMICS_BUILD_PREPARATION_POLICY, + simfileDynamics: { ...DYNAMICS_BUILD_PREPARATION_POLICY.simfileDynamics, runtimeExpressionUnwrap: ["parenthesized"] } + } + ]) { + assert.notEqual( + createDynamicsClosureIdentity(base).sha256, + createDynamicsClosureIdentity({ ...base, preparationPolicy }).sha256 + ); + } + }); + + it("never classifies declaration files as runtime transform inputs", () => { + for (const fileName of ["types.d.ts", "types.d.mts", "types.d.cts"]) { + assert.equal(isDeclarationFile(fileName), true); + assert.equal(isTransformableTypeScriptSource(fileName), false); + } + assert.equal(isTransformableTypeScriptSource("provider.ts"), true); + assert.equal(isTransformableTypeScriptSource("provider.mjs"), true); + }); + + it("prepares repeatable .mjs and .ts artifacts without evaluating authored code", async () => { + const project = await createBuildTestProject(); + const marker = `${project.directory}/executed`; + try { + await writeBuildFile(project, "systems/provider.mjs", `import { writeFileSync } from 'node:fs'; writeFileSync(${JSON.stringify(marker)}, 'top'); export const create = () => writeFileSync(${JSON.stringify(marker)}, 'factory');`); + await assert.rejects(prepareBuild(project), /unsupported external import: node:fs/u); + await assert.rejects(readFile(marker)); + await writeBuildFile(project, "systems/provider.mjs", "export const value = 1;\n"); + await writeBuildFile(project, "systems/provider.ts", "import type { DynamicsJsonValue } from 'simfile/dynamics'; export const value: DynamicsJsonValue = 1;\n"); + const mjs = await prepareBuild(project); + const ts = await prepareBuild(project, "./systems/provider.ts"); + assert.deepEqual(mjs.artifactBytes, (await prepareBuild(project)).artifactBytes); + assert.equal(ts.typecheckMode, "typescript"); + assert.match(artifactText(ts), /closure-sha256/u); + assert.doesNotMatch(artifactText(ts), /simfile\/dynamics/u); + } finally { + await removeBuildTestPaths(project.directory); + } + }); + + it("rejects runtime imports of the Simfile type surface", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/provider.ts", "import { DYNAMICS_LIMITS } from 'simfile/dynamics'; export const value = DYNAMICS_LIMITS;\n"); + await assert.rejects(prepareBuild(project, "./systems/provider.ts"), /erased type-only/u); + } finally { + await removeBuildTestPaths(project.directory); + } + }); + + it("permits every erased type-only form and rejects runtime type-surface forms", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/provider.ts", [ + "import type { DynamicsJsonValue } from 'simfile/dynamics';", + "import type * as Dynamics from 'simfile/dynamics';", + "import { type DynamicsProvider } from 'simfile/dynamics';", + "export type { DynamicsJsonValue } from 'simfile/dynamics';", + "export { type DynamicsProvider } from 'simfile/dynamics';", + "export type * from 'simfile/dynamics';", + "import type {} from 'simfile/dynamics';", + "export type {} from 'simfile/dynamics';", + "type Imported = import('simfile/dynamics').DynamicsJsonValue | Dynamics.DynamicsJsonValue;", + "export const value: Imported = 1;" + ].join("\n")); + const erased = await prepareBuild(project, "./systems/provider.ts"); + assert.doesNotMatch(artifactText(erased), /simfile\/dynamics/u); + const shadow = { directory: `${project.directory}/node_modules/simfile` }; + await writeBuildFile(shadow, "package.json", JSON.stringify({ name: "simfile", version: "1.0.0", type: "module", exports: { "./dynamics": "./dynamics.mjs" } })); + await writeBuildFile(shadow, "dynamics.mjs", "export const value = 1;\n"); + const runtimeForms = [ + "import 'simfile/dynamics'; export const value = 1;", + "import value from 'simfile/dynamics'; export { value };", + "import * as value from 'simfile/dynamics'; export { value };", + "import { value } from 'simfile/dynamics'; export { value };", + "export { value } from 'simfile/dynamics';", + "export * from 'simfile/dynamics';", + "export * as value from 'simfile/dynamics';", + "import value = require('simfile/dynamics'); export { value };", + "export const value = import('simfile/dynamics');", + "export const value = require('simfile/dynamics');" + ]; + for (const source of runtimeForms) { + await writeBuildFile(project, "systems/provider.ts", source); + await assert.rejects(prepareBuild(project, "./systems/provider.ts"), /erased type-only/u); + } + await writeBuildFile(project, "systems/provider.mjs", "export const value = import('simfile/dynamics');\n"); + await assert.rejects(prepareBuild(project), /erased type-only/u); + } finally { + await removeBuildTestPaths(project.directory); + } + }); + + it("erases type import-equals and rejects empty, mixed, and indirect runtime surface forms", async () => { + const project = await createBuildTestProject(); + try { + const source = "import type Dynamics = require('simfile/dynamics'); export const value: Dynamics.DynamicsJsonValue = 1;"; + await writeBuildFile(project, "systems/provider.ts", source); + const before = await prepareBuild(project, "./systems/provider.ts"); + const shadow = { directory: `${project.directory}/node_modules/simfile` }; + await writeBuildFile(shadow, "package.json", JSON.stringify({ name: "simfile", version: "1.0.0", type: "module", exports: { "./dynamics": "./dynamics.mjs" } })); + await writeBuildFile(shadow, "dynamics.mjs", "export const value = 1;\n"); + const after = await prepareBuild(project, "./systems/provider.ts"); + assert.deepEqual(after.artifactBytes, before.artifactBytes); + assert.doesNotMatch(artifactText(after), /simfile\/dynamics/u); + for (const runtime of [ + "import {} from 'simfile/dynamics'; export const value = 1;", + "export {} from 'simfile/dynamics'; export const value = 1;", + "import value, { type DynamicsJsonValue } from 'simfile/dynamics'; export { value };", + "export const value = import('simfile/dynamics', {});", + "export const value = (require)('simfile/dynamics');", + "export const value = (0, require)('simfile/dynamics');", + "export const value = require.call(null, 'simfile/dynamics');", + "export const value = require?.('simfile/dynamics');" + ]) { + await writeBuildFile(project, "systems/provider.ts", runtime); + await assert.rejects(prepareBuild(project, "./systems/provider.ts"), /erased type-only/u); + } + } finally { await removeBuildTestPaths(project.directory); } + }); + + it("permits erased empty type import/export forms while rejecting runtime equivalents", async () => { + const project = await createBuildTestProject(); + try { + for (const source of [ + "import type {} from 'simfile/dynamics';\nexport const value = 1;", + "export type {} from 'simfile/dynamics';\nexport const value = 1;" + ]) { + await writeBuildFile(project, "systems/provider.ts", source); + const prepared = await prepareBuild(project, "./systems/provider.ts"); + assert.doesNotMatch(artifactText(prepared), /simfile\/dynamics/u); + } + for (const source of [ + "import {} from 'simfile/dynamics';\nexport const value = 1;", + "export {} from 'simfile/dynamics';\nexport const value = 1;" + ]) { + await writeBuildFile(project, "systems/provider.ts", source); + await assert.rejects(prepareBuild(project, "./systems/provider.ts"), /erased type-only/u); + } + } finally { await removeBuildTestPaths(project.directory); } + }); + + it("returns deeply immutable portable results", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/provider.mjs", "export const value = 1;\n"); + const prepared = await prepareDynamicsBuild(project.simfilePath, "./systems/provider.mjs"); + assert.equal(Object.isFrozen(prepared), true); + assert.equal(Object.isFrozen(prepared.artifactBytes), true); + assert.equal(Object.isFrozen(prepared.inputs), true); + assert.equal(Object.isFrozen(prepared.closureDescriptor), true); + assert.throws(() => (prepared.artifactBytes as number[])[0] = 0, TypeError); + assert.doesNotMatch(JSON.stringify(prepared), new RegExp(project.directory.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"), "u")); + } finally { + await removeBuildTestPaths(project.directory); + } + }); +}); diff --git a/src/dynamics/build.ts b/src/dynamics/build.ts new file mode 100644 index 0000000..1787445 --- /dev/null +++ b/src/dynamics/build.ts @@ -0,0 +1,395 @@ +import path from "node:path"; +import { build, version as esbuildVersion } from "esbuild"; +import ts from "typescript"; + +import { DYNAMICS_BUILD_CONTRACT, DYNAMICS_BUILD_PREPARATION_POLICY } from "./buildInput.js"; +import { compareUtf16, createDynamicsClosureIdentity, deepFreeze, sha256, + type DynamicsBuildInputDescriptor } from "./buildIdentity.js"; +import { + assertNoRuntimeDynamicsSurface, + isDeclarationFile, + isRejectedRuntimeTypeScriptSource, + isTypeScriptFamily, + isTransformableTypeScriptSource, + dynamicsDefaultLibraryRoot, + dynamicsConfiguredTypeEntries, + dynamicsConfiguredTypeRoots, + dynamicsTypeSurfacePath, + typecheckDynamicsModule +} from "./buildTypecheck.js"; +import { createStaticCompilerReadGuard, type StaticCompilerReadGuard } from "./buildStaticCompilerHostPolicy.js"; +import { + assertStaticEmittedEsm, + assertStaticOwnershipEdge, + assertStaticPathReference, + assertStaticSource, + auditStaticMetafileImports, + classifyStaticModuleSpecifier, + DYNAMICS_STATIC_CLOSURE_POLICY, + staticPreserveSymlinks, + type StaticMetafileImport +} from "./buildStaticPolicy.js"; +import { + assertStaticSourceFileName, + checkedProjectPackageEntry, + preflightStaticGraph, + staticResolutionOptions, + staticRuntimeResolutionOptions, + validateStaticRuntimeSource as validateRuntimeSource, + validateStaticPackageSearchPaths, + type StaticRuntimeSourceBinding as RuntimeSourceBinding, + type StaticSourceSpecifier +} from "./buildStaticResolverPolicy.js"; +import { assertExactRuntimeInputs, descriptorForStaticSource, preflightStaticRuntimeGraph, sortStaticInputs, staticTypeSurfaceDescriptor } from "./buildStaticGraphPolicy.js"; +import { declarationBackedRuntimeRoots, enclosingPackageFor, + type PackageIdentity } from "./buildPackagePolicy.js"; +import { createDynamicsBuildSourceSnapshot, + type DynamicsBuildSourceSnapshot } from "./buildSourceSnapshot.js"; +import { resolveDynamicsModule } from "./modulePath.js"; +type InputMode = "runtime" | "type-only"; +interface StaticImportRecord extends StaticMetafileImport { + readonly original?: string; +} +type StaticResolutionPurpose = "runtime" | "typecheck"; +export interface PreparedDynamicsBuild { + /** Deferred P2: readonly number[] has excessive cost; Uint8Array is a later public-surface change. */ + readonly artifactBytes: readonly number[]; + readonly artifactSha256: string; + readonly closureDescriptor: Readonly>; + readonly closureSha256: string; + readonly inputs: readonly DynamicsBuildInputDescriptor[]; + readonly module: string; + readonly nodeExternals: readonly string[]; + readonly typecheckMode: "none" | "typescript"; +} +const { + esbuild: esbuildPolicy, + source: sourcePolicy, + simfileDynamics, + typescript: typescriptPolicy +} = DYNAMICS_BUILD_PREPARATION_POLICY; +const closurePreparationPolicy = deepFreeze({ + ...DYNAMICS_BUILD_PREPARATION_POLICY, + staticClosure: DYNAMICS_STATIC_CLOSURE_POLICY +}); +const ambiguousJavaScriptExtension = sourcePolicy.ambiguousJavaScriptExtension; +const simfileSpecifierPattern = new RegExp(`^${simfileDynamics.moduleSpecifier.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`); +const transpileModuleCompilerOptions = (() => { + const module = esbuildPolicy.onLoadTranspile.module === "ESNext" ? ts.ModuleKind.ESNext : (() => { + throw new Error(`unsupported prepare transform module setting: ${esbuildPolicy.onLoadTranspile.module}`); + })(); + const target = esbuildPolicy.onLoadTranspile.target === "ES2022" ? ts.ScriptTarget.ES2022 : (() => { + throw new Error(`unsupported prepare transform target setting: ${esbuildPolicy.onLoadTranspile.target}`); + })(); + return { module, target } as const; +})(); +const resolveStaticGraphSpecifier = async ( + specifier: StaticSourceSpecifier, + importer: RuntimeSourceBinding, + projectRoot: string, + readGuard: StaticCompilerReadGuard, + purpose: StaticResolutionPurpose +): Promise => { + if (specifier.specifier === simfileDynamics.moduleSpecifier) return importer.fileName; + if (specifier.kind === "module" && specifier.specifier.startsWith("node:")) return undefined; + if (specifier.kind === "path-reference") { + assertStaticPathReference(specifier.specifier, DYNAMICS_STATIC_CLOSURE_POLICY); + return path.resolve(path.dirname(importer.fileName), specifier.specifier); + } + const typeOnlyLegacyBuiltin = (specifier.mode === "type-only" || isDeclarationFile(importer.fileName)) && !specifier.specifier.startsWith("node:") + && DYNAMICS_STATIC_CLOSURE_POLICY.specifier.bareBuiltinNames.includes(specifier.specifier); + if (typeOnlyLegacyBuiltin) return undefined; + const kind = classifyStaticModuleSpecifier(specifier.specifier, DYNAMICS_STATIC_CLOSURE_POLICY); + if (kind === "package") { + await validateStaticPackageSearchPaths(specifier.specifier, importer.fileName, projectRoot); + } + const needsRuntimeBytes = purpose === "runtime" && specifier.kind === "module" + && specifier.mode === "runtime" + && !isDeclarationFile(importer.fileName); + const resolved = specifier.kind === "type-reference" + ? ts.resolveTypeReferenceDirective(specifier.specifier, importer.fileName, staticResolutionOptions, readGuard.preflightResolutionHost).resolvedTypeReferenceDirective + : ts.resolveModuleName(specifier.specifier, importer.fileName, + needsRuntimeBytes ? staticRuntimeResolutionOptions : staticResolutionOptions, + readGuard.preflightResolutionHost).resolvedModule; + if (!resolved?.resolvedFileName && specifier.kind === "module" + && kind === "relative") { + return path.resolve(path.dirname(importer.fileName), specifier.specifier); + } + const packageEntry = !resolved?.resolvedFileName && specifier.kind === "module" + ? await checkedProjectPackageEntry(specifier.specifier, projectRoot) : undefined; + if (!resolved?.resolvedFileName && !packageEntry) throw new Error(`dynamics build has incomplete static resolution evidence: ${specifier.specifier}`); + return resolved?.resolvedFileName ?? packageEntry; +}; + +const preflightSources = async ( + entries: readonly string[], + projectRoot: string, + readGuard: StaticCompilerReadGuard, + snapshot: DynamicsBuildSourceSnapshot, + checkStaticSource = true, + purpose: StaticResolutionPurpose = "runtime" +): Promise => + preflightStaticGraph(entries, DYNAMICS_STATIC_CLOSURE_POLICY, { + edge: (importer, target, specifier) => { + if (specifier.specifier === simfileDynamics.moduleSpecifier) return; + const kind = specifier.kind === "path-reference" ? "relative" : classifyStaticModuleSpecifier(specifier.specifier, DYNAMICS_STATIC_CLOSURE_POLICY); + assertStaticOwnershipEdge(importer.boundary, target.boundary, kind, specifier.specifier); + if (kind === "package" && target.kind !== "package") throw new Error(`dynamics build resolved a package import through a non-package path: ${specifier.specifier}`); + }, + inspect: (source, text) => { + if (source.kind === "project" && source.fileName.endsWith(ambiguousJavaScriptExtension)) { + throw new Error(`dynamics build rejects ambiguous authored ${ambiguousJavaScriptExtension} source: ${source.fileName}`); + } + if (isRejectedRuntimeTypeScriptSource(source.fileName)) throw new Error(`dynamics build rejects unsupported reachable TypeScript source: ${source.fileName}`); + assertNoRuntimeDynamicsSurface(source.fileName, text); + if (checkStaticSource) try { + assertStaticSource(source.fileName, text, DYNAMICS_STATIC_CLOSURE_POLICY); + } catch (error) { + const builtin = error instanceof Error ? /static source uses an unapproved node builtin: (.+)$/.exec(error.message) : null; + if (builtin?.[1] !== undefined) { + throw new Error(`unsupported external import: ${builtin[1]}`); + } + throw error; + } + }, + read: async (fileName) => { + readGuard.assertPreflightRead(fileName); + return snapshot.readText(fileName); + }, + resolve: (specifier, importer) => + resolveStaticGraphSpecifier(specifier, importer, projectRoot, readGuard, purpose), + validate: (fileName) => validateRuntimeSource(fileName, projectRoot, snapshot) + }); + +/** + * Prepares a deterministic, in-memory closure without importing authored code + * or invoking provider factories. + */ +/** @internal Injects retained-byte authority for deterministic boundary tests. */ +export const prepareDynamicsBuildWithSourceSnapshot = async ( + simfilePath: string, + moduleReference: string, + sourceSnapshot: DynamicsBuildSourceSnapshot +): Promise => { + const resolved = await resolveDynamicsModule(simfilePath, moduleReference); + const typeRoots = dynamicsConfiguredTypeRoots(); + const readGuard = createStaticCompilerReadGuard([ + resolved.projectRoot, + path.dirname(path.dirname(resolved.projectRoot)), + ...typeRoots.map((root) => path.dirname(root)), + ...typeRoots.map((root) => path.dirname(path.dirname(root))), + path.dirname(path.dirname(dynamicsTypeSurfacePath())) + ], dynamicsDefaultLibraryRoot(), { ...ts.sys, readFile: sourceSnapshot.readTextSync }); + const preflighted = await preflightSources( + [resolved.absolutePath], resolved.projectRoot, readGuard, sourceSnapshot); + const sourceBindings = new Map(preflighted.map((source) => [source.fileName, source])); + const expectedRuntimeInputs = await preflightStaticRuntimeGraph( + [preflighted[0] as RuntimeSourceBinding], sourceBindings, + async (fileName) => { + readGuard.assertPreflightRead(fileName); + return sourceSnapshot.readText(fileName); + }, + async (specifier, importer) => specifier.specifier === simfileDynamics.moduleSpecifier + ? undefined + : resolveStaticGraphSpecifier(specifier, importer, resolved.projectRoot, readGuard, "runtime") + ); + const result = await build({ + absWorkingDir: resolved.projectRoot, + bundle: DYNAMICS_BUILD_CONTRACT.esbuild.bundle, + charset: DYNAMICS_BUILD_CONTRACT.esbuild.charset, + entryPoints: [resolved.module.slice(2)], + external: [...DYNAMICS_STATIC_CLOSURE_POLICY.specifier.approvedNodeBuiltins], + format: DYNAMICS_BUILD_CONTRACT.esbuild.format, + ignoreAnnotations: esbuildPolicy.ignoreAnnotations, + legalComments: DYNAMICS_BUILD_CONTRACT.esbuild.legalComments, + metafile: true, + outfile: "dynamics.mjs", + platform: DYNAMICS_BUILD_CONTRACT.esbuild.platform, + preserveSymlinks: staticPreserveSymlinks(), + plugins: [{ + name: "simfile-dynamics-runtime-forbidden", + setup: (pluginBuild) => { + pluginBuild.onLoad({ filter: /.*/, namespace: "file" }, async (argument) => { + const source = await validateRuntimeSource( + argument.path, resolved.projectRoot, sourceSnapshot); + if (!expectedRuntimeInputs.has(source.fileName)) throw new Error(`esbuild attempted a runtime read absent from immutable preflight evidence: ${source.fileName}`); + assertStaticSourceFileName(source.fileName, DYNAMICS_STATIC_CLOSURE_POLICY); + if (isDeclarationFile(source.fileName)) return; + const text = await sourceSnapshot.readText(source.fileName); + try { + assertNoRuntimeDynamicsSurface(source.fileName, text); + assertStaticSource(source.fileName, text, DYNAMICS_STATIC_CLOSURE_POLICY); + } catch (error: unknown) { + if (error instanceof Error) { + const unsupportedNodeBuiltinMatch = /static source uses an unapproved node builtin: (.+)$/.exec(error.message); + if (unsupportedNodeBuiltinMatch?.[1] !== undefined) { + throw new Error(`unsupported external import: ${unsupportedNodeBuiltinMatch[1]}`); + } + } + throw error; + } + const contents = isTransformableTypeScriptSource(source.fileName) + ? ts.transpileModule(text, { + compilerOptions: { module: transpileModuleCompilerOptions.module, target: transpileModuleCompilerOptions.target } + }).outputText + : text; + return { + contents, + loader: esbuildPolicy.onLoadTranspile.loader + }; + }); + pluginBuild.onResolve({ filter: simfileSpecifierPattern }, () => ({ + errors: [{ text: simfileDynamics.runtimeResolutionFailure }] + })); + } + }], + sourcemap: DYNAMICS_BUILD_CONTRACT.esbuild.sourcemap, + splitting: false, + target: DYNAMICS_BUILD_CONTRACT.esbuild.target, + tsconfigRaw: { + compilerOptions: { + ...DYNAMICS_BUILD_CONTRACT.typescript, + allowImportingTsExtensions: typescriptPolicy.compilerOptions.allowImportingTsExtensions, + module: typescriptPolicy.compilerOptions.module, + moduleResolution: typescriptPolicy.compilerOptions.moduleResolution, + target: typescriptPolicy.compilerOptions.target, + allowJs: typescriptPolicy.allowJs, + checkJs: typescriptPolicy.checkJs + } + }, + write: false + }); + + if (!result.metafile) throw new Error("esbuild did not produce the fixed dynamics output"); + const output = result.outputFiles.find((file) => path.basename(file.path) === "dynamics.mjs") ?? result.outputFiles[0]; + if (!output) throw new Error("esbuild did not produce the fixed dynamics output"); + const outputImports = Object.entries(result.metafile.outputs).flatMap(([, outputMetadata]) => + [...(outputMetadata.imports ?? [])] + ) as StaticImportRecord[]; + assertStaticEmittedEsm(output.path, new TextDecoder().decode(output.contents), DYNAMICS_STATIC_CLOSURE_POLICY); + const nodeExternals = auditStaticMetafileImports(outputImports, DYNAMICS_STATIC_CLOSURE_POLICY); + + const uniqueRuntimeInputs = [...new Set(Object.keys(result.metafile.inputs).map((input) => path.resolve(resolved.projectRoot, input)))] + .sort(compareUtf16); + + assertExactRuntimeInputs(expectedRuntimeInputs, uniqueRuntimeInputs); + + const modeEntries = new Map>(uniqueRuntimeInputs.map((fileName) => [fileName, new Set(["runtime"]) ])); + + for (const [input, imports] of Object.entries(result.metafile.inputs)) { + const importer = sourceBindings.get(path.resolve(resolved.projectRoot, input)); + if (!importer) throw new Error(`runtime source was not preflighted for import checks: ${input}`); + for (const imported of imports.imports as StaticImportRecord[] | undefined ?? []) { + if (imported.external) continue; + if (typeof imported.original !== "string" || typeof imported.path !== "string") throw new Error("dynamics build has incomplete import resolution evidence"); + const target = sourceBindings.get(path.resolve(resolved.projectRoot, imported.path)); + if (!target) throw new Error(`runtime import was not independently preflighted: ${imported.original}`); + const kind = classifyStaticModuleSpecifier(imported.original, DYNAMICS_STATIC_CLOSURE_POLICY); + assertStaticOwnershipEdge(importer.boundary, target.boundary, kind, imported.original); + } + } + + const typecheckMode = uniqueRuntimeInputs.some(isTypeScriptFamily) ? "typescript" as const : "none" as const; + const typePreflight = typecheckMode === "typescript" + ? await preflightSources([ + ...preflighted.filter((source) => isTypeScriptFamily(source.fileName)).map((source) => source.fileName), + dynamicsTypeSurfacePath(), + ...dynamicsConfiguredTypeEntries(readGuard.preflightResolutionHost) + ], resolved.projectRoot, readGuard, sourceSnapshot, false, "typecheck") + : []; + for (const source of typePreflight) sourceBindings.set(source.fileName, source); + const declarationBackedRoots = declarationBackedRuntimeRoots( + uniqueRuntimeInputs, + sourceBindings, + typePreflight, + [...typePreflight].flatMap((source) => { + if (!source.fileName.endsWith(".d.ts")) return []; + const text = sourceSnapshot.readTextSync(source.fileName) ?? ""; + const markerStart = text.indexOf("DYNAMICS_DECLARATION_BACKED_MODULES"); + const marker = text.slice(markerStart, text.indexOf(";", markerStart) + 1); + return [...marker.matchAll(/"([^"]+[.]mjs)"/gu)].map((match) => + path.resolve(path.dirname(source.fileName), match[1] as string)); + })); + const typecheck = typecheckMode === "typescript" + ? typecheckDynamicsModule(resolved.absolutePath, resolved.projectRoot, + uniqueRuntimeInputs, readGuard, declarationBackedRoots) + : undefined; + + for (const fileName of typecheck?.inputPaths ?? []) { + const checked = await validateRuntimeSource( + fileName, resolved.projectRoot, sourceSnapshot); + const modes = modeEntries.get(checked.fileName) ?? new Set(); + modes.add("type-only"); + modeEntries.set(checked.fileName, modes); + sourceBindings.set(checked.fileName, checked); + } + + let simfileTypeSurface: Readonly<{ files: readonly string[]; identity: PackageIdentity }> | undefined; + if (typecheck?.typeSurface) { + const identity = await enclosingPackageFor( + typecheck.typeSurface.root, sourceSnapshot.readBytes); + if (!identity || identity.name !== "simfile") throw new Error("Simfile dynamics type surface has no simfile package identity"); + const files = [...modeEntries.keys()].filter((fileName) => { + const source = sourceBindings.get(fileName); + return source?.kind === "package" && source.identity?.directory === identity.directory; + }).sort(compareUtf16); + const included = new Set(files); + for (const typeSurfaceFile of typecheck.typeSurface.files) { + const validated = await validateRuntimeSource( + typeSurfaceFile, resolved.projectRoot, sourceSnapshot); + if (!included.has(validated.fileName)) throw new Error(`Simfile dynamics type surface file lacks same-package evidence: ${validated.fileName}`); + } + for (const fileName of files) modeEntries.delete(fileName); + simfileTypeSurface = { files, identity }; + } + + await sourceSnapshot.verifyAll(); + const inputs = await Promise.all([...modeEntries.entries()].map(([fileName, modes]) => { + const source = sourceBindings.get(fileName); + if (!source) throw new Error(`runtime source has no ownership evidence: ${fileName}`); + return descriptorForStaticSource( + source, modes, resolved.projectRoot, sourceSnapshot.readRetainedBytes); + })); + if (simfileTypeSurface) { + inputs.push(await staticTypeSurfaceDescriptor( + simfileTypeSurface.files, + simfileTypeSurface.identity.directory, + simfileTypeSurface.identity, + sourceSnapshot.readRetainedBytes + )); + } + const sortedInputs = sortStaticInputs(inputs); + const closure = createDynamicsClosureIdentity({ + buildContract: DYNAMICS_BUILD_CONTRACT, + entry: resolved.module, + esbuildVersion, + inputs: sortedInputs, + preparationPolicy: closurePreparationPolicy, + typecheckMode, + typescriptVersion: ts.version, + usedNodeBuiltins: nodeExternals + }); + const header = new TextEncoder().encode(closure.header); + const artifact = new Uint8Array(header.length + output.contents.length); + artifact.set(header); + artifact.set(output.contents, header.length); + return deepFreeze({ + artifactBytes: Array.from(artifact), + artifactSha256: sha256(artifact), + closureDescriptor: closure.descriptor, + closureSha256: closure.sha256, + inputs: sortedInputs, + module: resolved.module, + nodeExternals, + typecheckMode + }); +}; + +export const prepareDynamicsBuild = ( + simfilePath: string, + moduleReference: string +): Promise => prepareDynamicsBuildWithSourceSnapshot( + simfilePath, + moduleReference, + createDynamicsBuildSourceSnapshot() +); diff --git a/src/dynamics/buildDeterminism.test.ts b/src/dynamics/buildDeterminism.test.ts new file mode 100644 index 0000000..41a1072 --- /dev/null +++ b/src/dynamics/buildDeterminism.test.ts @@ -0,0 +1,398 @@ +import assert from "node:assert/strict"; +import { mkdtemp } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { + assertNoFilesystemPathLeaks, + artifactText, + collectStringValues, + assertAxisMutationHeaderEffect, + makePackage, + assertPreparedSingleInputMutation, + closureHeader, + buildShapeFromPrepared, + createBuildTestProject, + runAxisMutationFixture, + runPreparedSingleInputMutationFixture, + assertUnreachableFileMutationNoEffect, + LocaleChildResult, + LocaleProcessLocale, + prepareBuild, + prepareBuildInLocaleChild, + PreparedBuild, + removeBuildTestPaths, + writeBuildFile +} from "./buildTestSupport.test-helper.js"; +import { + compareUtf16, + createDynamicsClosureIdentity, + type DynamicsBuildInputDescriptor +} from "./buildIdentity.js"; +import { DYNAMICS_BUILD_PREPARATION_POLICY } from "./buildInput.js"; +import { DYNAMICS_STATIC_CLOSURE_POLICY } from "./buildStaticPolicy.js"; +import { assertStaticEmittedEsm } from "./buildStaticPolicy.js"; + +type UnknownRecord = Readonly>; + +const localeControls = ["ä", "z"] as const; +const expectedLocaleEnvironment: Record = { + "en-US": "en_US.UTF-8", + "sv-SE": "sv_SE.UTF-8" +}; +const expectedLocaleSort: Record = { + "en-US": ["ä", "z"], + "sv-SE": ["z", "ä"] +}; +const expectedPolicy = { + ...DYNAMICS_BUILD_PREPARATION_POLICY, + staticClosure: DYNAMICS_STATIC_CLOSURE_POLICY +} as const; +const INPUT_MUTATION_KEYS = { + commentSource: "project:./systems/provider.mjs", + typesSource: "project:./systems/types.ts", + packageSource: "package:identity-pkg:./index.mjs", + manifestVersion: "package:identity-pkg:./index.mjs" +} as const; +const PROJECT_KEYS = ["kind", "modes", "path", "sha256"] as const; +const PACKAGE_KEYS = ["kind", "manifest_sha256", "modes", "package_name", "package_path", "package_version", "sha256"] as const; +const TYPE_ONLY_KEYS = ["files", "kind", "manifest_sha256", "package_name", "package_version", "surface"] as const; + +type Prepared = PreparedBuild; +const asRecord = (value: unknown): UnknownRecord => + (value && typeof value === "object" && !Array.isArray(value)) ? value as UnknownRecord : {}; +const asStrings = (value: unknown): readonly string[] => Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +const asDescriptors = (value: unknown): readonly DynamicsBuildInputDescriptor[] => Array.isArray(value) + ? value.filter((entry): entry is DynamicsBuildInputDescriptor => typeof entry === "object" && entry !== null) + : []; +const sortByPath = (entries: readonly Entry[]): readonly Entry[] => + [...entries].sort((left: Entry, right: Entry): number => compareUtf16(left.path, right.path)); + +const makeLocaleFixture = async (directory: string): Promise => { + await writeBuildFile({ directory }, "systems/ä.mjs", "export const a = 1;\n"); + await writeBuildFile({ directory }, "systems/z.mjs", "export const z = 2;\n"); + await writeBuildFile({ directory }, "systems/provider.mjs", "import { a } from './ä.mjs';\nimport { z } from './z.mjs';\nexport const value = a + z;\n"); +}; + + +const freezeRecursively = (value: unknown): void => { + if (!value || typeof value !== "object") return; + assert.equal(Object.isFrozen(value), true); + for (const child of Object.values(value as Record)) freezeRecursively(child); +}; + +const assertCanonicalDescriptorKeys = (value: unknown, parentKey?: string, skipSorted = false): void => { + if (!value || typeof value !== "object") return; + if (Array.isArray(value)) { + for (const child of value) assertCanonicalDescriptorKeys(child, undefined, skipSorted); + return; + } + const record = value as Record; + const keys = Object.keys(record); + const isPolicyOrContract = parentKey === "preparation_policy" || parentKey === "build_contract"; + if (!skipSorted && !isPolicyOrContract) { + assert.deepEqual(keys, [...keys].sort(compareUtf16)); + } + const childSkipSorted = skipSorted || isPolicyOrContract; + for (const key of keys) assertCanonicalDescriptorKeys(record[key], key, childSkipSorted); +}; + +const assertLocaleResult = (result: LocaleChildResult, locale: LocaleProcessLocale): void => { + assert.equal(result.requestedLocale, locale); + assert.equal(result.localeEnvironment.lang, expectedLocaleEnvironment[locale]); + assert.equal(result.localeEnvironment.lcAll, expectedLocaleEnvironment[locale]); + assert.equal(result.resolvedLocale.toLowerCase().startsWith(locale.toLowerCase()), true); + assert.deepEqual(result.controlOrder, expectedLocaleSort[locale]); + assert.deepEqual([...localeControls].sort((left, right) => new Intl.Collator(result.resolvedLocale, { sensitivity: "variant" }).compare(left, right)), expectedLocaleSort[locale]); +}; + +const assertPreparedShape = (prepared: Prepared): void => { + for (const input of prepared.inputs) { + if (input.kind === "project") { + assert.deepEqual(Object.keys(input), PROJECT_KEYS); + assert.equal(input.path.startsWith("./"), true); + assert.deepEqual(input.modes, [...input.modes].sort(compareUtf16)); + assert.equal(path.isAbsolute(input.path), false); + } else if (input.kind === "package") { + assert.deepEqual(Object.keys(input), PACKAGE_KEYS); + assert.equal(input.package_path.startsWith("./"), true); + assert.deepEqual(input.modes, [...input.modes].sort(compareUtf16)); + assert.equal(path.isAbsolute(input.package_path), false); + } else { + assert.deepEqual(Object.keys(input), TYPE_ONLY_KEYS); + assert.equal(input.surface, "dynamics"); + assert.deepEqual(input.files, sortByPath(input.files)); + for (const file of input.files) assert.equal(path.isAbsolute(file.path), false); + } + } + assert.deepEqual(Object.keys(prepared.closureDescriptor), [...Object.keys(prepared.closureDescriptor)].sort(compareUtf16)); + assertCanonicalDescriptorKeys(prepared.closureDescriptor); + assert.deepEqual( + prepared.inputs, + [...prepared.inputs].sort((left, right) => + compareUtf16(JSON.stringify(left), JSON.stringify(right))) + ); +}; + +const assertNoLeakInPaths = (prepared: Prepared, projectDirectory: string): void => { + assertNoFilesystemPathLeaks(prepared, projectDirectory); + const roots = [path.resolve(projectDirectory), path.resolve(path.dirname(projectDirectory))]; + const seen = [...collectStringValues(prepared), artifactText(prepared)]; + for (const value of seen) { + for (const root of roots) assert.equal(value.includes(root), false); + } +}; + +const assertPairwisePreparedMatch = (prepareds: readonly Prepared[]): void => { + const [first, ...rest] = prepareds; + for (const prepared of rest) { + assert.deepEqual(first, prepared); + assert.deepEqual(closureHeader(first), closureHeader(prepared)); + assert.deepEqual(first.closureDescriptor, prepared.closureDescriptor); + assert.deepEqual(first.artifactBytes, prepared.artifactBytes); + } +}; + +const expectedPreparedPolicyText = JSON.stringify(expectedPolicy); + +describe("dynamics build determinism proof matrix", () => { + it("forces locale-sensitive child-process builds across two absolute roots", async () => { + const rootOne = await mkdtemp(path.join(os.tmpdir(), "simfile-locale-one-")); + const rootTwo = await mkdtemp(path.join(os.tmpdir(), "simfile-locale-two-")); + try { + const projectOne = await createBuildTestProject(rootOne); + const projectTwo = await createBuildTestProject(rootTwo); + await makeLocaleFixture(projectOne.directory); + await makeLocaleFixture(projectTwo.directory); + const children = await Promise.all([ + prepareBuildInLocaleChild(projectOne.directory, "en-US"), + prepareBuildInLocaleChild(projectOne.directory, "sv-SE"), + prepareBuildInLocaleChild(projectTwo.directory, "en-US"), + prepareBuildInLocaleChild(projectTwo.directory, "sv-SE") + ]); + assert.equal(new Set(children.map((entry) => entry.childPid)).size, 4); + const [enOne, svOne, enTwo, svTwo] = children; + assertLocaleResult(enOne, "en-US"); + assertLocaleResult(svOne, "sv-SE"); + assertLocaleResult(enTwo, "en-US"); + assertLocaleResult(svTwo, "sv-SE"); + assert.notDeepEqual(enOne.controlOrder, svOne.controlOrder); + assert.notDeepEqual(enTwo.controlOrder, svTwo.controlOrder); + assertPairwisePreparedMatch(children.map((entry) => entry.prepared)); + const checked: ReadonlyArray<[LocaleChildResult, string]> = [ + [enOne, projectOne.directory], + [svOne, projectOne.directory], + [enTwo, projectTwo.directory], + [svTwo, projectTwo.directory] + ]; + for (const [child, fixtureRoot] of checked) { + const names = new Set(child.prepared.inputs.filter((input): input is Extract => input.kind === "project").map((entry) => entry.path)); + assert.equal(names.has("./systems/ä.mjs"), true); + assert.equal(names.has("./systems/z.mjs"), true); + assert.equal(names.has("./systems/provider.mjs"), true); + assertPreparedShape(child.prepared); + assertNoFilesystemPathLeaks(child.prepared, fixtureRoot); + } + } finally { + await removeBuildTestPaths(rootOne, rootTwo); + } + }); + + it("keeps locale and comment sensitivity for source", async () => { + await runPreparedSingleInputMutationFixture( + (project) => writeBuildFile(project, "systems/provider.mjs", "// base\nexport const value = 1;\n"), + async (project) => { + await writeBuildFile(project, "systems/provider.mjs", "// changed\nexport const value = 1;\n"); + return prepareBuild(project); + }, + INPUT_MUTATION_KEYS.commentSource + ); + }); + + it("keeps type-only sensitivity", async () => { + await runPreparedSingleInputMutationFixture( + (project) => Promise.all([ + writeBuildFile(project, "systems/provider.ts", "import type { Marker } from './types.ts';\nexport const value: Marker = 'ok';\n"), + writeBuildFile(project, "systems/types.ts", "export type Marker = string;\n") + ]), + async (project) => { + await writeBuildFile(project, "systems/types.ts", "export type Marker = string | number;\n"); + return prepareBuild(project, "./systems/provider.ts"); + }, + INPUT_MUTATION_KEYS.typesSource, + "./systems/provider.ts" + ); + }); + + it("keeps package-source sensitivity", async () => { + await runPreparedSingleInputMutationFixture( + async (project) => { + await makePackage(project.directory, "identity-pkg", "1.0.0", "export const value = 1;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { value } from 'identity-pkg';\nexport const output = value;\n"); + }, + async (project) => { + await writeBuildFile({ directory: path.join(project.directory, "node_modules", "identity-pkg") }, "index.mjs", "export const value = 2;\n"); + return prepareBuild(project); + }, + INPUT_MUTATION_KEYS.packageSource + ); + }); + + it("keeps manifest-version sensitivity", async () => { + const rootOne = await mkdtemp(path.join(os.tmpdir(), "simfile-manifest-root-")); + const rootTwo = await mkdtemp(path.join(os.tmpdir(), "simfile-manifest-root-")); + try { + const makeProject = async (parent: string, version: string): Promise => { + const project = await createBuildTestProject(parent); + await makePackage(project.directory, "identity-pkg", version, "export const value = 1;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { value } from 'identity-pkg';\nexport const output = value;\n"); + return prepareBuild(project); + }; + const baseline = await makeProject(rootOne, "1.0.0"); + const mutated = await makeProject(rootTwo, "1.0.1"); + assertPreparedSingleInputMutation({ + baseline, + mutated, + expectedChangedInputKey: INPUT_MUTATION_KEYS.manifestVersion + }); + } finally { + await removeBuildTestPaths(rootOne, rootTwo); + } + }); + + it("proves fixed preparation and static policy axis", async () => { + await runAxisMutationFixture((shape) => { + const prep = asRecord(shape.preparationPolicy); + const staticClosure = asRecord(prep.staticClosure); + const source = asRecord(staticClosure.source); + return createDynamicsClosureIdentity({ + ...shape, + preparationPolicy: { + ...prep, + staticClosure: { ...staticClosure, source: { ...source, scriptTarget: "ES2015" } } + } + }); + }); + }); + + it("proves esbuild version axis", async () => { + await runAxisMutationFixture((shape) => createDynamicsClosureIdentity({ + ...shape, + esbuildVersion: "0.0.0-tool" + })); + }); + + it("proves TypeScript version axis", async () => { + await runAxisMutationFixture((shape) => createDynamicsClosureIdentity({ + ...shape, + typescriptVersion: "9.9.9-tool" + })); + }); + + it("proves approved builtin policy axis", async () => { + await runAxisMutationFixture((shape) => createDynamicsClosureIdentity({ + ...shape, + preparationPolicy: { + ...shape.preparationPolicy, + nodeBuiltins: [...asStrings(shape.preparationPolicy.nodeBuiltins), "node:fs"] + } + })); + }); + + it("proves used builtin subset axis", async () => { + await runAxisMutationFixture((shape) => createDynamicsClosureIdentity({ + ...shape, + usedNodeBuiltins: ["node:crypto"] + })); + }); + + it("proves typecheck mode axis", async () => { + await runAxisMutationFixture((shape) => createDynamicsClosureIdentity({ + ...shape, + typecheckMode: shape.typecheckMode === "none" ? "typescript" : "none" + })); + }); + + it("does not change prepared result from unreachable mutation", async () => { + const project = await createBuildTestProject(); + try { + await assertUnreachableFileMutationNoEffect(project); + } finally { + await removeBuildTestPaths(project.directory); + } + }); + + it("keeps complex accepted result frozen and root-safe with retained node builtin", async () => { + const project = await createBuildTestProject(); + try { + await makePackage(project.directory, "complex-pkg", "1.0.0", "export const value = 2;\n"); + await writeBuildFile( + project, + "systems/provider.ts", + [ + "import type { DynamicsJsonValue } from 'simfile/dynamics';", + "import { randomUUID } from 'node:crypto';", + "import { value } from 'complex-pkg';", + "export const marker: DynamicsJsonValue = { value };", + "export const randomSeed = randomUUID;" + ].join("\n") + ); + const prepared = await prepareBuild(project, "./systems/provider.ts"); + freezeRecursively(prepared); + assertPreparedShape(prepared); + assertNoLeakInPaths(prepared, project.directory); + assert.equal(JSON.stringify(asRecord(prepared.closureDescriptor.preparation_policy)), expectedPreparedPolicyText); + assert.deepEqual(prepared.nodeExternals, ["node:crypto"]); + assert.deepEqual(asStrings(asRecord(prepared.closureDescriptor).used_node_builtins), ["node:crypto"]); + const artifact = artifactText(prepared); + assert.doesNotMatch(artifact, /globalThis|require\(|createRequire\(/u); + assertStaticEmittedEsm("dynamics.mjs", artifact); + const hasControl = [path.resolve(project.directory), path.resolve(path.dirname(project.directory))]; + for (const p of [...collectStringValues(prepared), artifact]) { + for (const root of hasControl) { + assert.equal(p.includes(root), false); + } + } + } finally { + await removeBuildTestPaths(project.directory); + } + }); + + it("proves recursive closure-identity invariance with locale-sensitive keys", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/provider.ts", "export const value = 1;\n"); + const baseline = await prepareBuild(project, "./systems/provider.ts"); + const shape = buildShapeFromPrepared(baseline); + const one = createDynamicsClosureIdentity({ ...shape, buildContract: { ...shape.buildContract, localeBucket: { z: 1, ä: 2 } } }); + const two = createDynamicsClosureIdentity({ ...shape, buildContract: { ...shape.buildContract, localeBucket: { ä: 2, z: 1 } } }); + assert.equal(one.sha256, two.sha256); + assert.equal(one.header, two.header); + assert.deepEqual(one.descriptor, two.descriptor); + assert.deepEqual(one, two); + const oneInputs = asDescriptors(one.descriptor.inputs as unknown); + const twoInputs = asDescriptors(two.descriptor.inputs as unknown); + const onePolicy = asRecord(one.descriptor.preparation_policy); + const twoPolicy = asRecord(two.descriptor.preparation_policy); + assert.deepEqual(oneInputs, twoInputs); + assert.deepEqual(onePolicy, twoPolicy); + assertCanonicalDescriptorKeys(one.descriptor); + for (let index = 0; index < oneInputs.length; index += 1) { + const left = oneInputs[index]; + const right = twoInputs[index]; + if (left.kind === "type-only") { + assert.equal(right.kind, "type-only"); + assert.deepEqual(sortByPath(left.files), right.kind === "type-only" ? sortByPath(right.files) : []); + } else { + assert.equal(right.kind, left.kind); + assert.deepEqual(left.modes, right.modes); + } + } + } finally { + await removeBuildTestPaths(project.directory); + } + }); +}); diff --git a/src/dynamics/buildHermetic.test.ts b/src/dynamics/buildHermetic.test.ts new file mode 100644 index 0000000..2027580 --- /dev/null +++ b/src/dynamics/buildHermetic.test.ts @@ -0,0 +1,348 @@ +import assert from "node:assert/strict"; +import { mkdtemp, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { artifactText, createBuildTestProject, prepareBuild, removeBuildTestPaths, writeBuildFile } from "./buildTestSupport.test-helper.js"; +import { nodeModulesPackageFor } from "./buildPackagePolicy.js"; + +const header = (prepared: Awaited>): string => + `/* simfile-dynamics-closure-sha256:${prepared.closureSha256} */\n`; + +const assertChanged = (before: Awaited>, after: Awaited>): void => { + assert.notEqual(after.closureSha256, before.closureSha256); + assert.notEqual(header(after), header(before)); + assert.notDeepEqual(after.artifactBytes, before.artifactBytes); +}; + +const addPackage = async (directory: string, name: string, version: string, source: string): Promise => { + await writeBuildFile({ directory }, `package.json`, JSON.stringify({ name, version, type: "module", main: "./index.mjs" })); + await writeBuildFile({ directory }, "index.mjs", source); +}; + +const addTypeScriptPackage = async (directory: string, name: string, source: string): Promise => { + await writeBuildFile({ directory }, "package.json", JSON.stringify({ name, version: "1.0.0", type: "module", main: "./index.ts" })); + await writeBuildFile({ directory }, "index.ts", source); +}; + +const addTypeScriptDeclarationPackage = async (directory: string, name: string, source: string): Promise => { + await writeBuildFile({ directory }, "package.json", JSON.stringify({ name, version: "1.0.0", type: "module", main: "./index.d.ts" })); + await writeBuildFile({ directory }, "index.d.ts", source); +}; + +const addJavaScriptPackage = async (directory: string, name: string, source: string, type: "module" | "commonjs"): Promise => { + await writeBuildFile({ directory }, "package.json", JSON.stringify({ name, version: "1.0.0", type, main: "./index.js" })); + await writeBuildFile({ directory }, "index.js", source); +}; + +describe("hermetic dynamics build preparation", () => { + it("keeps nested authored projects portable across absolute roots", async () => { + const firstRoot = await mkdtemp(path.join(os.tmpdir(), "simfile-ancestor-")); + const secondRoot = await mkdtemp(path.join(os.tmpdir(), "simfile-ancestor-")); + try { + for (const root of [firstRoot, secondRoot]) await writeFile(path.join(root, "package.json"), JSON.stringify({ name: "ancestor", type: "commonjs" })); + const first = await createBuildTestProject(path.join(firstRoot, "nested")); + const second = await createBuildTestProject(path.join(secondRoot, "nested")); + for (const project of [first, second]) { + await writeBuildFile(project, "systems/helper.mjs", "export const helper = 2;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { helper } from './helper.mjs'; export const value = helper;\n"); + } + const one = await prepareBuild(first); + const two = await prepareBuild(second); + assert.deepEqual(one.artifactBytes, two.artifactBytes); + assert.equal(one.closureSha256, two.closureSha256); + for (const prepared of [one, two]) { + assert.ok(prepared.inputs.every((input) => input.kind !== "project" || input.path.startsWith("./systems/"))); + assert.doesNotMatch(JSON.stringify(prepared), /simfile-ancestor-/u); + } + } finally { + await removeBuildTestPaths(firstRoot, secondRoot); + } + }); + + it("normalizes enclosing and project-local packages before project containment", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-packages-")); + try { + const project = await createBuildTestProject(path.join(root, "projects")); + await addPackage(path.join(root, "node_modules", "enclosing-pkg"), "enclosing-pkg", "1.0.0", "export const enclosing = 2;\n"); + await addPackage(path.join(project.directory, "node_modules", "local-pkg"), "local-pkg", "1.0.0", "export const local = 3;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { enclosing } from 'enclosing-pkg'; import { local } from 'local-pkg'; export const value = enclosing + local;\n"); + const prepared = await prepareBuild(project); + for (const packageName of ["enclosing-pkg", "local-pkg"]) { + assert.ok(prepared.inputs.some((input) => input.kind === "package" && input.package_name === packageName && input.package_path === "./index.mjs")); + } + assert.equal(prepared.inputs.some((input) => input.kind === "project" && input.path.includes("node_modules")), false); + } finally { + await removeBuildTestPaths(root); + } + }); + + it("binds isolated package source and manifest mutations into closure headers and artifacts", async () => { + const sourceRoot = await mkdtemp(path.join(os.tmpdir(), "simfile-package-source-")); + const manifestRoot = await mkdtemp(path.join(os.tmpdir(), "simfile-package-manifest-")); + try { + for (const [root, mutation] of [[sourceRoot, "source"], [manifestRoot, "manifest"]] as const) { + const project = await createBuildTestProject(path.join(root, "projects")); + const packageDirectory = path.join(project.directory, "node_modules", "identity-pkg"); + await addPackage(packageDirectory, "identity-pkg", "1.0.0", "export const value = 1;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { value } from 'identity-pkg'; export { value };\n"); + const before = await prepareBuild(project); + if (mutation === "source") await writeBuildFile({ directory: packageDirectory }, "index.mjs", "export const value = 2;\n"); + else await writeBuildFile({ directory: packageDirectory }, "package.json", JSON.stringify({ name: "identity-pkg", version: "1.0.1", type: "module", main: "./index.mjs" })); + assertChanged(before, await prepareBuild(project)); + } + } finally { + await removeBuildTestPaths(sourceRoot, manifestRoot); + } + }); + + it("ignores project package sideEffects for both identity and bytes", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "package.json", JSON.stringify({ name: "authored-project", sideEffects: true })); + await writeBuildFile(project, "systems/effect.mjs", "globalThis.__simfile_effect = true;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import './effect.mjs'; export const value = 1;\n"); + const before = await prepareBuild(project); + await writeBuildFile(project, "package.json", JSON.stringify({ name: "authored-project", sideEffects: false })); + const after = await prepareBuild(project); + assert.equal(after.closureSha256, before.closureSha256); + assert.deepEqual(after.artifactBytes, before.artifactBytes); + } finally { + await removeBuildTestPaths(project.directory); + } + }); + + it("uses the same host-owned NodeNext acceptance nested and standalone", async () => { + const ancestor = await mkdtemp(path.join(os.tmpdir(), "simfile-nodenext-")); + const standalone = await createBuildTestProject(); + try { + await writeFile(path.join(ancestor, "package.json"), JSON.stringify({ name: "ancestor", type: "commonjs" })); + const nested = await createBuildTestProject(path.join(ancestor, "nested")); + const source = "export const url = import.meta.url;\n"; + for (const project of [nested, standalone]) await writeBuildFile(project, "systems/provider.ts", source); + const nestedBuild = await prepareBuild(nested, "./systems/provider.ts"); + const standaloneBuild = await prepareBuild(standalone, "./systems/provider.ts"); + assert.deepEqual(nestedBuild.artifactBytes, standaloneBuild.artifactBytes); + assert.equal(nestedBuild.closureSha256, standaloneBuild.closureSha256); + } finally { + await removeBuildTestPaths(ancestor, standalone.directory); + } + }); + + it("ignores every authored project package type when checking NodeNext", async () => { + const commonjs = await createBuildTestProject(); + const module = await createBuildTestProject(); + try { + const source = "export const url = import.meta.url;\n"; + for (const project of [commonjs, module]) await writeBuildFile(project, "systems/provider.ts", source); + await writeBuildFile(commonjs, "systems/package.json", JSON.stringify({ type: "commonjs" })); + await writeBuildFile(module, "systems/package.json", JSON.stringify({ type: "module" })); + const first = await prepareBuild(commonjs, "./systems/provider.ts"); + const second = await prepareBuild(module, "./systems/provider.ts"); + assert.equal(first.closureSha256, second.closureSha256); + assert.deepEqual(first.artifactBytes, second.artifactBytes); + } finally { + await removeBuildTestPaths(commonjs.directory, module.directory); + } + }); + + it("does not permit @ts-nocheck to hide a semantic error", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/provider.ts", "// @ts-nocheck\nexport const value: string = 1;\n"); + await assert.rejects(prepareBuild(project, "./systems/provider.ts"), /rejects diagnostic suppression/u); + } finally { + await removeBuildTestPaths(project.directory); + } + }); + + it("rejects effective suppressions but accepts inert directive-looking text", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/provider.ts", [ + "// Documentation mentions @ts-ignore but this is not a directive.", + "export const plain = '@ts-nocheck';", + "export const template = `@ts-expect-error`;" + ].join("\n")); + await prepareBuild(project, "./systems/provider.ts"); + for (const directive of ["@ts-ignore", "@ts-expect-error"]) { + await writeBuildFile(project, "systems/provider.ts", `// ${directive}\nexport const bad: string = 1;\n`); + await assert.rejects(prepareBuild(project, "./systems/provider.ts"), /rejects diagnostic suppression/u); + } + await addTypeScriptPackage(path.join(project.directory, "node_modules", "unchecked-pkg"), "unchecked-pkg", "// @ts-nocheck\nexport const bad: string = 1;\n"); + await writeBuildFile(project, "systems/provider.ts", "import { bad } from 'unchecked-pkg'; export { bad };\n"); + await assert.rejects(prepareBuild(project, "./systems/provider.ts"), /rejects diagnostic suppression/u); + } finally { + await removeBuildTestPaths(project.directory); + } + }); + + it("checks reachable TypeScript from .ts and .mjs entries and leaves inert comments alone", async () => { + const project = await createBuildTestProject(); + try { + const bad = "export const value: string = 1;\n"; + for (const entry of ["./systems/provider.ts", "./systems/provider.mjs"]) { + await writeBuildFile(project, "systems/provider.ts", "import { value } from './helper.ts'; export { value };\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { value } from './helper.ts'; export { value };\n"); + await writeBuildFile(project, "systems/provider.cjs", "const { value } = require('./helper.ts'); module.exports = { value };\n"); + await writeBuildFile(project, "systems/helper.ts", bad); + await assert.rejects(prepareBuild(project, entry), /TypeScript check failed/u); + for (const directive of ["@ts-nocheck", "@ts-ignore", "@ts-expect-error"]) { + await writeBuildFile(project, "systems/helper.ts", `// ${directive}\n${bad}`); + await assert.rejects(prepareBuild(project, entry), /rejects diagnostic suppression/u); + } + } + await writeBuildFile(project, "systems/helper.ts", ["const text = '@ts-nocheck';", "const template = `@ts-ignore`;", "// prose mentions @ts-expect-error", "/* @ts-nocheck */", "export const value = 1; // @ts-ignore"].join("\n")); + assert.equal((await prepareBuild(project, "./systems/provider.mjs")).typecheckMode, "typescript"); + const packageDirectory = path.join(project.directory, "node_modules", "checked-pkg"); + const declarationPackageDirectory = path.join(project.directory, "node_modules", "checked-declarations"); + await writeBuildFile(project, "systems/provider.mjs", "import { value } from 'checked-pkg'; export { value };\n"); + for (const directive of ["@ts-nocheck", "@ts-ignore", "@ts-expect-error"]) { + await addTypeScriptPackage(packageDirectory, "checked-pkg", `// ${directive}\nexport const value: string = 1;\n`); + await assert.rejects(prepareBuild(project), /rejects diagnostic suppression/u); + } + await writeBuildFile(project, "systems/provider.mjs", "import { value } from 'checked-declarations'; export const value2 = value;\n"); + await addTypeScriptDeclarationPackage(declarationPackageDirectory, "checked-declarations", `// @ts-ignore\nexport const value: string = 1;\n`); + await assert.rejects(prepareBuild(project), /rejects diagnostic suppression/u); + for (const extension of ["mts", "cts", "tsx"]) { + await writeBuildFile(project, `systems/helper.${extension}`, "export const value = 1;\n"); + await writeBuildFile(project, "systems/provider.mjs", `import { value } from './helper.${extension}'; export { value };\n`); + await assert.rejects(prepareBuild(project), /unsupported reachable TypeScript/u); + } + await removeBuildTestPaths( + path.join(project.directory, "systems", "helper.mts"), + path.join(project.directory, "systems", "helper.cts"), + path.join(project.directory, "systems", "helper.tsx") + ); + for (const extension of [".d.mts", ".d.cts"] as const) { + await writeBuildFile(project, `systems/helper${extension}`, "export type Value = string;\n"); + await writeBuildFile(project, "systems/provider.ts", `import type { Value } from './helper${extension}'; export const local: Value = \"\";\n`); + const prepared = await prepareBuild(project, "./systems/provider.ts"); + assert.ok(prepared.inputs.some((input) => input.kind === "project" && input.path === `./systems/helper${extension}` && input.modes[0] === "type-only")); + } + } finally { await removeBuildTestPaths(project.directory); } + }); + + it("prefers package-local node_modules .js inputs before ambiguous authored .js", async () => { + const project = await createBuildTestProject(); + try { + const modulePackage = path.join(project.directory, "node_modules", "module-pkg"); + const commonjsPackage = path.join(project.directory, "node_modules", "commonjs-pkg"); + await addJavaScriptPackage(modulePackage, "module-pkg", "export const value = 1;\n", "module"); + await addJavaScriptPackage(commonjsPackage, "commonjs-pkg", "exports.value = 1;\n", "commonjs"); + await writeBuildFile(project, "systems/provider.mjs", "import { value as moduleValue } from 'module-pkg'; import * as commonjsValue from 'commonjs-pkg'; export const value = moduleValue + commonjsValue.value;\n"); + const prepared = await prepareBuild(project); + assert.ok(prepared.inputs.some((input) => input.kind === "package" && input.package_name === "module-pkg" && input.package_path === "./index.js")); + assert.ok(prepared.inputs.some((input) => input.kind === "package" && input.package_name === "commonjs-pkg" && input.package_path === "./index.js")); + await writeBuildFile(project, "systems/helper.js", "export const value = 1;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { value as moduleValue } from 'module-pkg'; import * as commonjsValue from 'commonjs-pkg'; import { value as helper } from './helper.js'; export const value = moduleValue + commonjsValue.value + helper;\n"); + await assert.rejects(prepareBuild(project), /ambiguous authored \.js/iu); + } finally { + await removeBuildTestPaths(project.directory); + } + }); + + it("fails closed for invalid package owners and preserves scoped portable descriptors", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-package-owner-")); + try { + const cases: readonly [string, string][] = [ + ["missing", ""], + ["malformed", "{"], + ["array", "[]"], + ["absent", "{}"], + ["non-string", JSON.stringify({ name: 1, version: 1 })], + ["empty", JSON.stringify({ name: "", version: "" })], + ["invalid", JSON.stringify({ name: "bad name", version: "version" })] + ]; + for (const [label, manifest] of cases) { + const project = await createBuildTestProject(path.join(root, label)); + const packageDirectory = path.join(project.directory, "node_modules", "invalid-pkg"); + await writeBuildFile({ directory: packageDirectory }, "index.mjs", "export const value = 1;\n"); + if (manifest) await writeBuildFile({ directory: packageDirectory }, "package.json", manifest); + await assert.rejects(nodeModulesPackageFor(path.join(packageDirectory, "index.mjs"))); + } + const project = await createBuildTestProject(path.join(root, "scoped")); + await addPackage(path.join(root, "node_modules", "@outer", "enclosing"), "@outer/enclosing", "1.2.3", "export const outer = 1;\n"); + await addPackage(path.join(project.directory, "node_modules", "@local", "owned"), "@local/owned", "2.3.4", "export const local = 2;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { outer } from '@outer/enclosing'; import { local } from '@local/owned'; export const value = outer + local;\n"); + const prepared = await prepareBuild(project); + assert.ok(prepared.inputs.some((input) => input.kind === "package" && input.package_name === "@outer/enclosing" && input.package_version === "1.2.3" && input.package_path === "./index.mjs")); + assert.ok(prepared.inputs.some((input) => input.kind === "package" && input.package_name === "@local/owned" && input.package_version === "2.3.4" && input.package_path === "./index.mjs")); + } finally { + await removeBuildTestPaths(root); + } + }); + + it("requires node_modules path ownership and real SemVer", async () => { + const project = await createBuildTestProject(); + try { + for (const [relative, name] of [["node_modules/plain", "other"], ["node_modules/@scope/pkg", "@scope/other"], ["node_modules/outer/node_modules/inner", "outer"]] as const) { + const directory = path.join(project.directory, relative); + await addPackage(directory, name, "1.0.0", "export const value = 1;\n"); + await writeBuildFile(project, "systems/provider.mjs", `import { value } from '${relative.includes("@scope") ? "@scope/pkg" : relative.includes("inner") ? "outer" : "plain"}'; export { value };\n`); + if (relative.includes("inner")) { + await addPackage(path.join(project.directory, "node_modules", "outer"), "outer", "1.0.0", "import { value } from 'inner'; export { value };\n"); + } + await assert.rejects(prepareBuild(project), /does not own its node_modules path/u); + } + const directory = path.join(project.directory, "node_modules", "semver-pkg"); + await addPackage(directory, "semver-pkg", "1.0.0-01", "export const value = 1;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { value } from 'semver-pkg'; export { value };\n"); + await assert.rejects(prepareBuild(project), /invalid package identity/u); + await addPackage(directory, "semver-pkg", "1.2.3-alpha.1+build.05", "export const value = 1;\n"); + await prepareBuild(project); + } finally { await removeBuildTestPaths(project.directory); } + }); + + it("fails closed for project .js while explicit module extensions ignore project type", async () => { + const first = await createBuildTestProject(); + const second = await createBuildTestProject(); + try { + for (const [project, type] of [[first, "commonjs"], [second, "module"]] as const) { + await writeBuildFile(project, "package.json", JSON.stringify({ name: "authored-project", type })); + await writeBuildFile(project, "systems/helper.js", "export const value = 1;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { value } from './helper.js'; export { value };\n"); + await assert.rejects(prepareBuild(project), /ambiguous authored .js/u); + await writeBuildFile(project, "systems/helper.mjs", "export const value = 1;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { value } from './helper.mjs'; export { value };\n"); + } + assert.deepEqual((await prepareBuild(first)).artifactBytes, (await prepareBuild(second)).artifactBytes); + for (const project of [first, second]) { + await writeBuildFile(project, "systems/helper.cjs", "exports.value = 1;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import helper from './helper.cjs'; export const value = helper.value;\n"); + } + assert.deepEqual((await prepareBuild(first)).artifactBytes, (await prepareBuild(second)).artifactBytes); + } finally { await removeBuildTestPaths(first.directory, second.directory); } + }); + + it("selects the nearest nested node_modules owner", async () => { + const project = await createBuildTestProject(); + try { + const outer = path.join(project.directory, "node_modules", "outer-pkg"); + const inner = path.join(outer, "node_modules", "inner-pkg"); + await addPackage(outer, "outer-pkg", "1.0.0", "import { inner } from 'inner-pkg'; export const outer = inner;\n"); + await addPackage(inner, "inner-pkg", "2.0.0", "export const inner = 2;\n"); + await writeBuildFile(project, "systems/provider.mjs", "import { outer } from 'outer-pkg'; export { outer };\n"); + const prepared = await prepareBuild(project); + assert.ok(prepared.inputs.some((input) => input.kind === "package" && input.package_name === "inner-pkg" && input.package_version === "2.0.0" && input.package_path === "./index.mjs")); + } finally { + await removeBuildTestPaths(project.directory); + } + }); + + it("never executes authored top-level or factory code while preparing", async () => { + const project = await createBuildTestProject(); + const marker = "__simfile_build_marker__"; + try { + delete (globalThis as Record)[marker]; + await writeBuildFile(project, "systems/provider.mjs", `globalThis[${JSON.stringify(marker)}] = 'top'; export const create = () => globalThis[${JSON.stringify(marker)}] = 'factory';`); + const prepared = await prepareBuild(project); + assert.equal((globalThis as Record)[marker], undefined); + assert.match(artifactText(prepared), /__simfile_build_marker__/u); + } finally { + delete (globalThis as Record)[marker]; + await removeBuildTestPaths(project.directory); + } + }); +}); diff --git a/src/dynamics/buildHostile.test.ts b/src/dynamics/buildHostile.test.ts new file mode 100644 index 0000000..89dbb1c --- /dev/null +++ b/src/dynamics/buildHostile.test.ts @@ -0,0 +1,351 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, symlink, unlink, writeFile } from "node:fs/promises"; +import { execFileSync } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { + artifactText, + createBuildTestProject, + prepareBuild, + removeBuildTestPaths, + writeBuildFile +} from "./buildTestSupport.test-helper.js"; + +const marker = "__SIMFILE_B54_HOSTILE_MARKER__"; +const withMarker = (source: string): string => `${source}\nglobalThis.${marker} = true;`; +const clearMarker = (): void => { + delete (globalThis as Record)[marker]; +}; +const assertMarkerAbsent = (): void => { + assert.equal((globalThis as Record)[marker], undefined); +}; + +const addPackage = async ( + directory: string, + name: string, + version: string, + source: string, + type: "module" | "commonjs" = "module" +): Promise => { + await mkdir(directory, { recursive: true }); + const entry = type === "commonjs" ? "index.cjs" : "index.mjs"; + await writeBuildFile({ directory }, "package.json", JSON.stringify({ name, version, type, main: `./${entry}` })); + await writeBuildFile({ directory }, entry, source); +}; + +const assertHostileSource = async ( + project: Parameters[0], + source: string, + expected: RegExp +): Promise => { + clearMarker(); + await writeBuildFile(project, "systems/provider.mjs", withMarker(source)); + await assert.rejects(prepareBuild(project), expected); + assertMarkerAbsent(); +}; + +describe("dynamics build hostile boundary probes", () => { + it("rejects entry, transitive, and package symlink escapes", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/real-provider.mjs", withMarker("export const value = 1;\n")); + await symlink(path.join(project.directory, "systems", "real-provider.mjs"), path.join(project.directory, "systems", "provider.mjs")); + await assert.rejects(prepareBuild(project), /must not contain symlinks|dynamics\.module|static source path/u); + assertMarkerAbsent(); + await unlink(path.join(project.directory, "systems", "provider.mjs")); + + await writeBuildFile(project, "systems/real-helper.mjs", "export const value = 2;\n"); + await symlink(path.join(project.directory, "systems", "real-helper.mjs"), path.join(project.directory, "systems", "link-helper.mjs")); + await assertHostileSource( + project, + "import { value } from './link-helper.mjs';\nexport const output = value;\n", + /static source path is not a regular file|must not contain symlinks|static source path/u + ); + + const packageDirectory = path.join(project.directory, "node_modules", "real-safe-pkg"); + const aliasDirectory = path.join(project.directory, "node_modules", "alias-safe-pkg"); + await addPackage(packageDirectory, "real-safe-pkg", "1.0.0", "export const value = 3;\n"); + await symlink(packageDirectory, aliasDirectory); + await assertHostileSource( + project, + "import { value } from 'alias-safe-pkg';\nexport const output = value;\n", + /static source path is not a regular file|s\.path is no longer contained|static source path|package manifest/u + ); + } finally { + clearMarker(); + await removeBuildTestPaths(project.directory); + } + }); + + it("rejects boundary escapes, unresolved imports, URL/absolute, and non-file paths", async () => { + const ancestor = await mkdtemp(path.join(os.tmpdir(), "simfile-b54-boundary-")); + const project = await createBuildTestProject(path.join(ancestor, "project")); + const packageOwner = path.join(ancestor, "node_modules", "owned-pkg"); + const packageProject = await createBuildTestProject(path.join(ancestor, "package-a")); + + try { + await writeFile(path.join(ancestor, "package-a", "package.json"), JSON.stringify({ name: "package-a", version: "1.0.0", type: "module" })); + await addPackage(path.join(ancestor, "node_modules", "sibling"), "sibling", "1.0.0", "export const value = 5;\n"); + await addPackage(path.join(ancestor, "package-a", "sibling"), "sibling", "1.0.0", "export const value = 5;\n"); + await writeFile(path.join(ancestor, "node_modules", "sibling", "index.d.mts"), "export declare const value: number;\n"); + await mkdir(path.join(project.directory, "systems", "not-a-file.mjs"), { recursive: true }); + await assert.rejects(prepareBuild(project, "./systems/not-a-file.mjs"), /static source path leaf is not a regular file|regular file/u); + + const fifoPath = path.join(project.directory, "systems", "fifo.mjs"); + if (process.platform !== "win32") { + execFileSync("mkfifo", [fifoPath]); + await assertHostileSource(project, "export { value } from './fifo.mjs';\n", /static source path leaf is not a regular file|static source path/u); + } + + await addPackage(packageOwner, "owned-pkg", "1.0.0", "export const value = 4;\n"); + await assertHostileSource( + project, + "import { value } from '../../../node_modules/owned-pkg/index.mjs';\n", + /relative import escape|outside|static source path|s\.path|dynamics TypeScript check failed|Cannot find module|TS2307|does not contain/u + ); + + await assertHostileSource( + project, + "import { value } from '../missing.mjs';\n", + /outside|does not contain|static source|resolve|missing|TS2307|Cannot find module/u + ); + + await assertHostileSource(packageProject, "import { value } from '../../sibling/index.mjs'; export { value };\n", /relative import escape/u); + await assertHostileSource( + packageProject, + "import 'missing-pkg';\n", + /incomplete static resolution evidence: missing-pkg/u + ); + + for (const source of [ + "import 'https://example.test/x.mjs';\n", + "import 'C:/tmp/outside.mjs';\n", + "import '/tmp/outside.mjs';\n" + ]) { + await assertHostileSource(packageProject, source, /static source|url|unsupported|resolved|absolute|dynamics build|resolve/i); + } + } finally { + clearMarker(); + await removeBuildTestPaths(ancestor); + } + }); + + it("rejects malformed dynamic/import and require matrix defects", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/local.mjs", "export const value = 1;\n"); + await assertHostileSource(project, "import { value } from 'simfile/dynamics';\n", /erased type-only|static source/i); + await assertHostileSource(project, "export const value = import('simfile/dynamics');\n", /erased type-only|static source/i); + await assertHostileSource(project, "export const value = require('simfile/dynamics');\n", /erased type-only|static source/i); + + const malformedDynamic = [ + "import('package-name');\n", + "import('node:crypto');\n", + "import('https://example.test/x.mjs');\n", + "import('/tmp/outside.mjs');\n", + "import('./' + 'local.mjs');\n", + "import('./local.mjs', { with: { type: 'json' } });\n" + ]; + for (const source of malformedDynamic) { + await assertHostileSource(project, source, /static source|dynamics build|unsupported|path/u); + } + + const badRequire = [ + "const src = './local.mjs'; const x = src; require(x);\n", + "const src = './local.mjs'; (require)(src);\n", + "const src = './local.mjs'; ((require))(src);\n", + "const src = './local.mjs'; (0, require)(src);\n", + "const src = './local.mjs'; require.call(null, src);\n", + "const src = './local.mjs'; const r = require; r(src);\n", + "const src = './local.mjs'; const r = (0, require); r(src);\n" + ]; + for (const source of badRequire) { + await assertHostileSource(project, source, /static source|unsupported|dynamics build|erased type-only/i); + } + + const legacyBuiltins = [ + "import 'crypto';\n", + "import 'node:fs';\n", + "import { value } from 'fs';\n", + "export const value = import('fs');\n" + ]; + for (const source of legacyBuiltins) { + await assertHostileSource(project, source, /static source|unsupported|dynamics build/i); + } + } finally { + clearMarker(); + await removeBuildTestPaths(project.directory); + } + }); + + it("accepts transitive project/package closure with local dynamic import", async () => { + const ancestor = await mkdtemp(path.join(os.tmpdir(), "simfile-b54-accept-")); + const project = await createBuildTestProject(path.join(ancestor, "project")); + + try { + await addPackage(path.join(ancestor, "node_modules", "enclosing-pkg"), "enclosing-pkg", "1.0.0", "export const enclosing = 11;\n"); + await addPackage(path.join(project.directory, "node_modules", "transitive-pkg"), "transitive-pkg", "1.0.0", "export const transitive = 3;\n"); + await addPackage(path.join(project.directory, "node_modules", "pkg"), "pkg", "1.0.0", "import { transitive } from 'transitive-pkg'; export const pkgValue = transitive + 1;\n"); + await writeBuildFile(project, "systems/local.mjs", "export const localValue = 1;\n"); + await writeBuildFile( + project, + "systems/provider.mjs", + withMarker( + "import { localValue } from './local.mjs';\n" + + "import { pkgValue } from 'pkg';\n" + + "import { enclosing } from 'enclosing-pkg';\n" + + "export const value = import('./local.mjs').then(({ localValue }) => localValue + pkgValue + enclosing);\n" + ) + ); + + const prepared = await prepareBuild(project); + assert.equal(prepared.nodeExternals.length, 0); + assert.doesNotMatch(artifactText(prepared), /\bimport\s*\(/u); + assert.equal(prepared.inputs.some((input) => input.kind === "project" && input.path === "./systems/local.mjs"), true); + assert.equal(prepared.inputs.some((input) => input.kind === "package" && input.package_name === "pkg"), true); + assert.equal(prepared.inputs.some((input) => input.kind === "package" && input.package_name === "transitive-pkg"), true); + assert.equal(prepared.inputs.some((input) => input.kind === "package" && input.package_name === "enclosing-pkg"), true); + assertMarkerAbsent(); + } finally { + clearMarker(); + await removeBuildTestPaths(ancestor); + } + }); + + it("accepts direct require, closed CJS, and exact builtin externals", async () => { + const direct = await createBuildTestProject(); + const cjs = await createBuildTestProject(); + + try { + await writeBuildFile(direct, "systems/dep.mjs", "export const value = 4;\n"); + await writeBuildFile(direct, "systems/provider.mjs", withMarker("const dep = require('./dep.mjs');\nexport const value = dep.value;\n")); + const directResult = await prepareBuild(direct); + assert.deepEqual(directResult.nodeExternals, []); + assertMarkerAbsent(); + + await writeBuildFile(direct, "systems/provider.mjs", withMarker( + "import { randomUUID } from 'node:crypto';\nimport { createHash } from 'node:crypto';\nexport const value = randomUUID() + createHash('sha256').digest('hex');\n" + )); + const cryptoOnce = await prepareBuild(direct); + assert.deepEqual(cryptoOnce.nodeExternals, ["node:crypto"]); + assertMarkerAbsent(); + + await addPackage(path.join(cjs.directory, "node_modules", "closed-cjs"), "closed-cjs", "1.0.0", "exports.value = 9;", "commonjs"); + await writeBuildFile(cjs, "systems/provider.mjs", withMarker("import value from 'closed-cjs';\nexport const output = value.value;\n")); + const closed = await prepareBuild(cjs); + assert.equal(closed.nodeExternals.length, 0); + assert.equal(closed.inputs.some((input) => input.kind === "package" && input.package_name === "closed-cjs"), true); + assertMarkerAbsent(); + + const empty = await createBuildTestProject(); + await writeBuildFile(empty, "systems/provider.mjs", withMarker("export const value = 1;\n")); + const noBuiltin = await prepareBuild(empty); + assert.deepEqual(noBuiltin.nodeExternals, []); + assertMarkerAbsent(); + await removeBuildTestPaths(empty.directory); + } finally { + clearMarker(); + await removeBuildTestPaths(direct.directory, cjs.directory); + } + }); + + it("rejects symlinked package ancestors and extensionless package entries before a read", async () => { + const project = await createBuildTestProject(); + const modules = await mkdtemp(path.join(os.tmpdir(), "simfile-b54-modules-")); + try { + await addPackage(path.join(modules, "ancestor-pkg"), "ancestor-pkg", "1.0.0", "export const value = 1;\n"); + await symlink(modules, path.join(project.directory, "node_modules")); + await assertHostileSource(project, "import { value } from 'ancestor-pkg'; export { value };\n", /static source path is not regular/u); + await unlink(path.join(project.directory, "node_modules")); + + const packageDirectory = path.join(project.directory, "node_modules", "extensionless-pkg"); + await mkdir(packageDirectory, { recursive: true }); + await writeBuildFile({ directory: packageDirectory }, "package.json", JSON.stringify({ name: "extensionless-pkg", version: "1.0.0", type: "module", main: "./entry" })); + await writeBuildFile({ directory: packageDirectory }, "real-entry", "this is invalid authored target bytes\n"); + await symlink(path.join(packageDirectory, "real-entry"), path.join(packageDirectory, "entry")); + await assertHostileSource(project, "import 'extensionless-pkg';\n", /static source path leaf is not a regular file|static source path is not regular/u); + } finally { + clearMarker(); + await removeBuildTestPaths(project.directory, modules); + } + }); + + it("rejects a type-only package through a symlinked node_modules path before TypeScript reads it", async () => { + const project = await createBuildTestProject(); + const modules = await mkdtemp(path.join(os.tmpdir(), "simfile-b54-types-")); + const evidence = "BarePackageTypeReadBeforePathGate"; + try { + const packageDirectory = path.join(modules, "type-escape"); + await mkdir(packageDirectory, { recursive: true }); + await writeBuildFile({ directory: packageDirectory }, "package.json", JSON.stringify({ + name: "type-escape", version: "1.0.0", types: "./index.d.ts", evidence + })); + await writeBuildFile({ directory: packageDirectory }, "index.d.ts", `export type Value = ${evidence};\n`); + await symlink(modules, path.join(project.directory, "node_modules")); + await writeBuildFile(project, "systems/provider.ts", `/// \nexport const value = 1;\n`); + await assert.rejects(prepareBuild(project, "./systems/provider.ts"), (error: Error) => { + assert.match(error.message, /static source path is not regular/u); + assert.doesNotMatch(error.message, new RegExp(evidence, "u")); + return true; + }); + assertMarkerAbsent(); + } finally { + clearMarker(); + await removeBuildTestPaths(project.directory, modules); + } + }); + + it("preflights triple-slash path references before TypeScript diagnostics", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/real-types.d.ts", "export type Value = MissingTypeReadBeforePathGate;\n"); + await symlink(path.join(project.directory, "systems", "real-types.d.ts"), path.join(project.directory, "systems", "linked-types.d.ts")); + await writeBuildFile(project, "systems/provider.ts", withMarker("/// \nexport const value = 1;\n")); + await assert.rejects(prepareBuild(project, "./systems/provider.ts"), (error: Error) => { + assert.match(error.message, /static source path leaf is not a regular file/u); + assert.doesNotMatch(error.message, /MissingTypeReadBeforePathGate/u); + return true; + }); + assertMarkerAbsent(); + await unlink(path.join(project.directory, "systems", "linked-types.d.ts")); + + await addPackage(path.join(project.directory, "node_modules", "type-pkg"), "type-pkg", "1.0.0", "export const runtime = 1;\n"); + await writeBuildFile(project, "node_modules/type-pkg/index.d.ts", "export type Value = number;\n"); + await writeBuildFile(project, "systems/provider.ts", withMarker("/// \nexport const value = 1;\n")); + await assert.rejects(prepareBuild(project, "./systems/provider.ts"), /relative import escape/u); + assertMarkerAbsent(); + + await writeBuildFile(project, "systems/nested-types.d.ts", "declare type LocalValue = number;\n"); + await writeBuildFile(project, "systems/linked-types.d.ts", "/// \n"); + await writeBuildFile(project, "systems/provider.ts", "/// \nexport const value: LocalValue = 1;\n"); + const prepared = await prepareBuild(project, "./systems/provider.ts"); + assert.equal(prepared.typecheckMode, "typescript"); + assertMarkerAbsent(); + } finally { + clearMarker(); + await removeBuildTestPaths(project.directory); + } + }); + + it("records guarded compiler-injected Node and Undici checked inputs", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/provider.ts", "export const value: number = 1;\n"); + const prepared = await prepareBuild(project, "./systems/provider.ts"); + assert.equal(prepared.inputs.some((input) => input.kind === "package" && input.package_name === "@types/node" && input.modes.includes("type-only")), true); + assert.equal(prepared.inputs.some((input) => input.kind === "package" && input.package_name === "undici-types" && input.modes.includes("type-only")), true); + } finally { await removeBuildTestPaths(project.directory); } + }); + + it("keeps a default value plus named type import in immutable runtime evidence", async () => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/runtime.ts", "export default 7;\nexport type RuntimeValue = number;\n"); + await writeBuildFile(project, "systems/provider.ts", "import value, { type RuntimeValue } from './runtime.ts';\nexport const output: RuntimeValue = value;\n"); + const prepared = await prepareBuild(project, "./systems/provider.ts"); + assert.equal(prepared.inputs.some((input) => input.kind === "project" && input.path === "./systems/runtime.ts" && input.modes.includes("runtime")), true); + } finally { await removeBuildTestPaths(project.directory); } + }); +}); diff --git a/src/dynamics/buildIdentity.ts b/src/dynamics/buildIdentity.ts new file mode 100644 index 0000000..32aa593 --- /dev/null +++ b/src/dynamics/buildIdentity.ts @@ -0,0 +1,92 @@ +import { createHash } from "node:crypto"; + +export type DynamicsBuildInputDescriptor = + | Readonly<{ + kind: "project"; + modes: readonly ("runtime" | "type-only")[]; + path: string; + sha256: string; + }> + | Readonly<{ + kind: "package"; + manifest_sha256: string; + modes: readonly ("runtime" | "type-only")[]; + package_name: string; + package_path: string; + package_version: string; + sha256: string; + }> + | Readonly<{ + files: readonly Readonly<{ path: string; sha256: string }> []; + kind: "type-only"; + manifest_sha256: string; + package_name: "simfile"; + package_version: string; + surface: "dynamics"; + }>; + +export interface DynamicsClosureIdentityInput { + readonly buildContract: unknown; + readonly entry: string; + readonly esbuildVersion: string; + readonly inputs: readonly DynamicsBuildInputDescriptor[]; + readonly preparationPolicy: unknown; + readonly typecheckMode: "none" | "typescript"; + readonly typescriptVersion: string; + readonly usedNodeBuiltins: readonly string[]; +} + +export interface DynamicsClosureIdentity { + readonly descriptor: Readonly>; + readonly header: string; + readonly sha256: string; +} + +export const compareUtf16 = (left: string, right: string): number => + left === right ? 0 : left < right ? -1 : 1; + +export const canonicalJson = (value: unknown): string => { + if (value === null || typeof value === "boolean" || typeof value === "string") return JSON.stringify(value); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error("build identity cannot contain a non-finite number"); + return JSON.stringify(value); + } + if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; + if (typeof value === "object") { + const record = value as Record; + return `{${Object.keys(record).sort(compareUtf16).map((key) => `${JSON.stringify(key)}:${canonicalJson(record[key])}`).join(",")}}`; + } + throw new Error("build identity must be JSON data"); +}; + +export const sha256 = (value: string | Uint8Array): string => createHash("sha256").update(value).digest("hex"); + +export const deepFreeze = (value: Value): Readonly => { + if (value && typeof value === "object" && !Object.isFrozen(value)) { + for (const child of Object.values(value as Record)) deepFreeze(child); + Object.freeze(value); + } + return value as Readonly; +}; + +/** Creates the portable, unversioned closure identity used in artifact headers. */ +export const createDynamicsClosureIdentity = ( + input: DynamicsClosureIdentityInput +): DynamicsClosureIdentity => { + const descriptor = deepFreeze({ + build_contract: input.buildContract, + entry: input.entry, + esbuild_version: input.esbuildVersion, + inputs: [...input.inputs], + preparation_policy: input.preparationPolicy, + typecheck_mode: input.typecheckMode, + typescript_version: input.typescriptVersion, + used_node_builtins: [...input.usedNodeBuiltins].sort(compareUtf16) + }); + const closureSha256 = sha256(canonicalJson(descriptor)); + return deepFreeze({ + descriptor, + header: `/* simfile-dynamics-closure-sha256:${closureSha256} */\n`, + sha256: closureSha256 + }); +}; diff --git a/src/dynamics/buildInput.ts b/src/dynamics/buildInput.ts new file mode 100644 index 0000000..cfac004 --- /dev/null +++ b/src/dynamics/buildInput.ts @@ -0,0 +1,114 @@ +const allowedExtensions = Object.freeze([".ts", ".mjs"] as const); + +const typescript = Object.freeze({ + strict: true, + noEmit: true, + target: "ES2022", + module: "NodeNext", + moduleResolution: "NodeNext" +} as const); + +const esbuild = Object.freeze({ + platform: "node", + format: "esm", + target: "node22", + bundle: true, + sourcemap: false, + legalComments: "none", + charset: "utf8" +} as const); + +/** Fixed, host-owned inputs for B11 dynamics preparation. */ +export const DYNAMICS_BUILD_CONTRACT = Object.freeze({ + allowedExtensions, + typescript, + esbuild +} as const); + +const nodeBuiltins = Object.freeze(["node:crypto"] as const); + +const preparationEsbuild = Object.freeze({ + ignoreAnnotations: true, + onLoadTranspile: Object.freeze({ + loader: "js", + module: "ESNext", + target: "ES2022" + } as const) +} as const); + +const preparationPackage = Object.freeze({ + manifestFileName: "package.json", + namePattern: "^(?:@[a-z0-9][a-z0-9._-]*\\/[a-z0-9][a-z0-9._-]*|[a-z0-9][a-z0-9._-]*)$", + nodeModulesDirectory: "node_modules", + scopePrefix: "@", + versionPattern: "^(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)\\.(?:0|[1-9]\\d*)(?:(?:-(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*)(?:\\.(?:0|[1-9]\\d*|[0-9A-Za-z-]*[A-Za-z-][0-9A-Za-z-]*))*)?(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*)?|(?:\\+[0-9A-Za-z-]+(?:\\.[0-9A-Za-z-]+)*))?$", +} as const); + +const preparationSource = Object.freeze({ + ambiguousJavaScriptExtension: ".js", + checkedExtensions: Object.freeze([".js", ".mjs", ".cjs", ".ts", ".tsx", ".mts", ".cts", ".d.ts", ".d.mts", ".d.cts"] as const), + declarationExtensions: Object.freeze([".d.ts", ".d.mts", ".d.cts"] as const), + javaScriptSyntaxExtensions: Object.freeze([".js", ".mjs", ".cjs"] as const), + rejectRuntimeTypeExtensions: Object.freeze([".tsx", ".mts", ".cts"] as const), + transformExtensions: Object.freeze([".ts", ".tsx", ".mts", ".cts", ".mjs"] as const) +} as const); + +const preparationSuppression = Object.freeze({ + commentKinds: Object.freeze(["single-line"] as const), + directivePrefix: "@ts-", + placement: "full-line", + fullLineDirectives: Object.freeze(["nocheck", "ignore", "expect-error"] as const) +} as const); + +const preparationTypeSurface = Object.freeze({ + acceptedErasedForms: Object.freeze([ + "import-clause", + "import-specifiers", + "export-declaration", + "export-specifiers", + "namespace-export", + "import-type", + "import-equals" + ] as const), + moduleSpecifier: "simfile/dynamics", + runtimeCallForms: Object.freeze(["dynamic-import", "require-direct", "require-call"] as const), + runtimeExpressionUnwrap: Object.freeze([ + "parenthesized", + "as-expression", + "type-assertion", + "non-null", + "comma-right" + ] as const), + runtimeResolutionFailure: "dynamics build permits only erased type-only simfile/dynamics references" +} as const); + +const preparationTypescript = Object.freeze({ + allowJs: true, + checkJs: true, + compilerOptions: Object.freeze({ + allowImportingTsExtensions: true, + module: "NodeNext", + moduleResolution: "NodeNext", + target: "ES2022" + } as const), + projectPackageType: "module", + rejectDiagnosticSuppressions: true, + runtimeNoDtsResolution: true, + typeRootPackage: "@types/node", + typeRootResolution: "package-parent", + types: Object.freeze(["node"] as const) +} as const); + +/** + * B12-only preparation rules. This is intentionally separate from the B11 + * contract above, which is consumed by the public schema and must not drift. + */ +export const DYNAMICS_BUILD_PREPARATION_POLICY = Object.freeze({ + esbuild: preparationEsbuild, + nodeBuiltins, + package: preparationPackage, + source: preparationSource, + simfileDynamics: preparationTypeSurface, + suppression: preparationSuppression, + typescript: preparationTypescript +} as const); diff --git a/src/dynamics/buildLoad.test-helper.ts b/src/dynamics/buildLoad.test-helper.ts new file mode 100644 index 0000000..70b4f73 --- /dev/null +++ b/src/dynamics/buildLoad.test-helper.ts @@ -0,0 +1,89 @@ +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { type TestContext } from "node:test"; +import { type PreparedDynamicsBuild } from "./build.js"; +import { createDynamicsBuildReceipt, type DynamicsBuildReceipt } from "./buildReceipt.js"; +import { createSyntheticMjsFixture } from "./buildReceipt.test-helper.js"; +import { sha256 } from "./buildIdentity.js"; +import { removeBuildTestPaths } from "./buildTestSupport.test-helper.js"; +import { + persistDynamicsBuild, + type DynamicsBuildArtifactLifecycle, + type PersistDynamicsBuildOptions +} from "./buildLoad.js"; + +export interface BuildLoadFixture { + readonly absoluteSimfilePath: string; + readonly evidenceRoot: string; + readonly prepared: PreparedDynamicsBuild; + readonly projectRoot: string; + readonly receipt: DynamicsBuildReceipt; + readonly scratchRoot: string; + readonly sourcePath: string; + persist(overrides?: Partial): Promise; +} + +export const preparedWithBody = ( + prepared: PreparedDynamicsBuild, + body: string +): PreparedDynamicsBuild => { + const header = `/* simfile-dynamics-closure-sha256:${prepared.closureSha256} */\n`; + const artifactBytes = new TextEncoder().encode(`${header}${body}`); + return { + ...prepared, + artifactBytes: Array.from(artifactBytes), + artifactSha256: sha256(artifactBytes) + }; +}; + +export const createBuildLoadFixture = async ( + testContext: TestContext, + artifactBody?: string +): Promise => { + const synthetic = await createSyntheticMjsFixture(); + const roots: string[] = []; + let cleaned = false; + const cleanup = async (): Promise => { + if (cleaned) return; + await Promise.all(roots.map((root) => rm(root, { force: true, recursive: true }))); + await removeBuildTestPaths(synthetic.projectRoot); + cleaned = true; + }; + testContext.after(cleanup); + let scratchRoot: string; + let evidenceRoot: string; + let prepared: PreparedDynamicsBuild; + let receipt: DynamicsBuildReceipt; + try { + const temporaryRoot = await realpath(os.tmpdir()); + scratchRoot = await mkdtemp(path.join(temporaryRoot, "simfile-build-load-scratch-")); + roots.push(scratchRoot); + evidenceRoot = await mkdtemp(path.join(temporaryRoot, "simfile-build-load-evidence-")); + roots.push(evidenceRoot); + prepared = artifactBody === undefined + ? synthetic.prepared + : preparedWithBody(synthetic.prepared, artifactBody); + receipt = await createDynamicsBuildReceipt(synthetic.absoluteSimfilePath, prepared); + } catch (error) { + await cleanup(); + throw error; + } + const defaults: PersistDynamicsBuildOptions = { + absoluteSimfilePath: synthetic.absoluteSimfilePath, + evidenceRoot, + prepared, + receipt, + scratchRoot + }; + return { + absoluteSimfilePath: synthetic.absoluteSimfilePath, + evidenceRoot, + prepared, + projectRoot: synthetic.projectRoot, + receipt, + scratchRoot, + sourcePath: path.join(synthetic.projectRoot, "systems", "provider.mjs"), + persist: (overrides = {}) => persistDynamicsBuild({ ...defaults, ...overrides }) + }; +}; diff --git a/src/dynamics/buildLoad.test.ts b/src/dynamics/buildLoad.test.ts new file mode 100644 index 0000000..3937dae --- /dev/null +++ b/src/dynamics/buildLoad.test.ts @@ -0,0 +1,316 @@ +import assert from "node:assert/strict"; +import { + access, + chmod, + lstat, + mkdir, + readFile, + readdir, + rm, + symlink, + writeFile +} from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { + createBuildLoadFixture, + preparedWithBody +} from "./buildLoad.test-helper.js"; + +const assertMissing = async (fileName: string): Promise => { + await assert.rejects(() => access(fileName), { code: "ENOENT" }); +}; + +test("persists exact read-only content-addressed bytes, imports only the artifact, copies evidence, and cleans idempotently", async (t) => { + const marker = "__simfileBuildLoadFactoryInvoked"; + t.after(() => { delete (globalThis as Record)[marker]; }); + const fixture = await createBuildLoadFixture(t, [ + "export const artifactOnly = 37;", + `export const createDynamicsProvider = () => { globalThis.${marker} = true; return {}; };` + ].join("\n")); + const lifecycle = await fixture.persist(); + const expectedArtifact = path.join( + fixture.scratchRoot, + "dynamics", + `sha256-${fixture.prepared.artifactSha256}`, + "provider.mjs" + ); + const expectedReceipt = path.join(fixture.scratchRoot, "dynamics", "build-receipt.json"); + assert.equal(lifecycle.artifactPath, expectedArtifact); + assert.equal(lifecycle.receiptPath, expectedReceipt); + assert.deepEqual([...await readFile(expectedArtifact)], fixture.prepared.artifactBytes); + assert.deepEqual([...await readFile(expectedReceipt)], fixture.receipt.receiptBytes); + assert.equal((await lstat(expectedArtifact)).mode & 0o222, 0); + assert.equal((await lstat(expectedReceipt)).mode & 0o222, 0); + + const imported = await lifecycle.importArtifact(); + assert.equal(imported.artifactOnly, 37); + assert.equal(imported.value, undefined); + assert.equal(typeof imported.createDynamicsProvider, "function"); + assert.equal((globalThis as Record)[marker], undefined); + assert.deepEqual( + [...await readFile(lifecycle.evidence?.artifactPath ?? "")], + fixture.prepared.artifactBytes + ); + assert.deepEqual( + [...await readFile(lifecycle.evidence?.receiptPath ?? "")], + fixture.receipt.receiptBytes + ); + assert.equal((await lstat(lifecycle.evidence?.artifactPath ?? "")).mode & 0o222, 0); + assert.equal((await lstat(lifecycle.evidence?.receiptPath ?? "")).mode & 0o222, 0); + + await lifecycle.cleanup(); + await lifecycle.cleanup(); + await assertMissing(expectedArtifact); + await assertMissing(expectedReceipt); +}); + +test("rejects missing, malformed, mismatched, and wrongly addressed receipts or artifacts", async (t) => { + const fixture = await createBuildLoadFixture(t); + await assert.rejects(() => fixture.persist({ receipt: undefined }), /receipt is missing or malformed/u); + await assert.rejects( + () => fixture.persist({ + receipt: { + ...fixture.receipt, + receiptSha256: "0".repeat(64) + } + }), + /bytes\/hash mismatch/u + ); + await assert.rejects( + () => fixture.persist({ + receipt: { + ...fixture.receipt, + payload: { + ...fixture.receipt.payload, + artifact_path: "./dynamics/sha256-deadbeef/provider.mjs" + } + } + }), + /payload mismatch/u + ); + await assert.rejects( + () => fixture.persist({ + prepared: { + ...fixture.prepared, + artifactBytes: [...fixture.prepared.artifactBytes, 0] + } + }), + /artifact SHA mismatch/u + ); + await assertMissing(path.join(fixture.scratchRoot, "dynamics")); +}); + +test("rejects non-absolute and overlapping roots including dot-dot-prefixed child names", async (t) => { + const fixture = await createBuildLoadFixture(t); + await assert.rejects(() => fixture.persist({ scratchRoot: "relative" }), /must be absolute/u); + const deceptiveChild = path.join(fixture.scratchRoot, "..evil"); + await mkdir(deceptiveChild); + await assert.rejects( + () => fixture.persist({ evidenceRoot: deceptiveChild }), + /must not overlap/u + ); + await assertMissing(path.join(fixture.scratchRoot, "dynamics")); +}); + +test("fails closed on missing or mutated persisted artifact and receipt", async (t) => { + const fixture = await createBuildLoadFixture(t); + for (const target of ["artifact", "receipt"] as const) { + const lifecycle = await fixture.persist(); + const fileName = target === "artifact" ? lifecycle.artifactPath : lifecycle.receiptPath; + await chmod(fileName, 0o600); + await writeFile(fileName, "hostile mutation\n"); + await assert.rejects(() => lifecycle.verify(), /read-only|bytes mismatch/u); + await lifecycle.cleanup(); + } + + const lifecycle = await fixture.persist(); + await rm(lifecycle.receiptPath); + await assert.rejects(() => lifecycle.verify(), /receipt is missing/u); + await lifecycle.cleanup(); + + const missingArtifact = await fixture.persist(); + await rm(missingArtifact.artifactPath); + await assert.rejects(() => missingArtifact.verify(), /artifact is missing/u); + await missingArtifact.cleanup(); +}); + +test("source drift after persistence fails before artifact top-level evaluation", async (t) => { + const marker = "__simfileBuildLoadSourceDrift"; + const fixture = await createBuildLoadFixture( + t, + `globalThis.${marker} = true;\nexport const createDynamicsProvider = () => ({});\n` + ); + t.after(() => { delete (globalThis as Record)[marker]; }); + const lifecycle = await fixture.persist(); + await writeFile(fixture.sourcePath, "export const value = 2;\n"); + await assert.rejects( + () => lifecycle.importArtifact(), + /prepared project descriptor mismatch/u + ); + assert.equal((globalThis as Record)[marker], undefined); + await assertMissing(lifecycle.artifactPath); +}); + +const mutatingArtifactBodies = { + artifact: [ + 'import { chmodSync, writeFileSync } from "node:fs";', + 'import { fileURLToPath } from "node:url";', + "const target = fileURLToPath(import.meta.url);", + "chmodSync(target, 0o600);", + 'writeFileSync(target, "mutated artifact\\n");', + "export const createDynamicsProvider = () => ({});" + ].join("\n"), + receipt: [ + 'import { chmodSync, writeFileSync } from "node:fs";', + 'import { fileURLToPath } from "node:url";', + 'const target = fileURLToPath(new URL("../build-receipt.json", import.meta.url));', + "chmodSync(target, 0o600);", + 'writeFileSync(target, "mutated receipt\\n");', + "export const createDynamicsProvider = () => ({});" + ].join("\n") +} as const; + +for (const [target, body] of Object.entries(mutatingArtifactBodies)) { + test(`detects ${target} mutation during top-level import before returning a factory`, async (t) => { + const fixture = await createBuildLoadFixture(t, `${body}\n`); + const lifecycle = await fixture.persist(); + await assert.rejects( + () => lifecycle.importArtifact(), + /read-only|bytes mismatch/u + ); + await assertMissing(lifecycle.artifactPath); + assert.deepEqual( + [...await readFile(lifecycle.evidence?.artifactPath ?? "")], + fixture.prepared.artifactBytes + ); + assert.deepEqual( + [...await readFile(lifecycle.evidence?.receiptPath ?? "")], + fixture.receipt.receiptBytes + ); + }); +} + +test("detects authored-source mutation during top-level import before returning exports", async (t) => { + const environmentKey = "SIMFILE_BUILD_LOAD_MUTATE_SOURCE"; + const previous = process.env[environmentKey]; + t.after(() => { + if (previous === undefined) delete process.env[environmentKey]; + else process.env[environmentKey] = previous; + }); + const fixture = await createBuildLoadFixture(t, [ + 'import { writeFileSync } from "node:fs";', + `const source = process.env.${environmentKey};`, + 'if (!source) throw new Error("missing mutation source");', + 'writeFileSync(source, "export const drifted = true;\\n");', + "export const createDynamicsProvider = () => ({});" + ].join("\n")); + process.env[environmentKey] = fixture.sourcePath; + const lifecycle = await fixture.persist(); + await assert.rejects( + () => lifecycle.importArtifact(), + /prepared project descriptor mismatch/u + ); + await assertMissing(lifecycle.artifactPath); + assert.ok(lifecycle.evidence); +}); + +test("preserves a verified evidence pair when artifact evaluation throws and cleans scratch", async (t) => { + const fixture = await createBuildLoadFixture(t, 'throw new Error("fixture import failure");\n'); + const lifecycle = await fixture.persist(); + await assert.rejects(() => lifecycle.importArtifact(), /fixture import failure/u); + await assertMissing(lifecycle.artifactPath); + const evidence = lifecycle.evidence; + assert.ok(evidence); + assert.deepEqual([...await readFile(evidence.artifactPath)], fixture.prepared.artifactBytes); + assert.deepEqual([...await readFile(evidence.receiptPath)], fixture.receipt.receiptBytes); +}); + +test("evidence copy failure preserves caller bytes and leaves no partial artifact or temp", async (t) => { + const fixture = await createBuildLoadFixture(t); + const lifecycle = await fixture.persist(); + const evidenceDynamics = path.join(fixture.evidenceRoot, "dynamics"); + const evidenceReceipt = path.join(evidenceDynamics, "build-receipt.json"); + await mkdir(evidenceDynamics); + await writeFile(evidenceReceipt, "caller sentinel\n"); + await assert.rejects(() => lifecycle.copyEvidence(), /target already exists/u); + assert.equal(await readFile(evidenceReceipt, "utf8"), "caller sentinel\n"); + assert.deepEqual(await readdir(evidenceDynamics), ["build-receipt.json"]); + await lifecycle.verify(); + await lifecycle.cleanup(); +}); + +test("cleanup failure remains retryable and removes only lifecycle-owned paths", async (t) => { + const fixture = await createBuildLoadFixture(t); + const lifecycle = await fixture.persist(); + const artifactDirectory = path.dirname(lifecycle.artifactPath); + const blocker = path.join(artifactDirectory, "caller-blocker"); + await writeFile(blocker, "block cleanup\n"); + await assert.rejects(() => lifecycle.cleanup(), { code: "ENOTEMPTY" }); + assert.equal(await readFile(blocker, "utf8"), "block cleanup\n"); + await rm(blocker); + await lifecycle.cleanup(); + await lifecycle.cleanup(); + await assertMissing(path.join(fixture.scratchRoot, "dynamics")); +}); + +test("64 simultaneous cleanup calls join one idempotent owned-path removal", async (t) => { + const fixture = await createBuildLoadFixture(t); + const lifecycle = await fixture.persist(); + const callerSentinel = path.join(fixture.scratchRoot, "caller-sentinel"); + await writeFile(callerSentinel, "outside lifecycle ownership\n"); + const cleanups = Array.from({ length: 64 }, () => lifecycle.cleanup()); + assert.equal(cleanups.every((cleanup) => cleanup === cleanups[0]), true); + const settled = await Promise.allSettled(cleanups); + assert.equal(settled.every((result) => result.status === "fulfilled"), true); + await assertMissing(path.join(fixture.scratchRoot, "dynamics")); + assert.equal(await readFile(callerSentinel, "utf8"), "outside lifecycle ownership\n"); + await lifecycle.cleanup(); +}); + +test("symlink roots and pre-existing content-address symlinks are rejected without deletion", async (t) => { + const fixture = await createBuildLoadFixture(t); + const linkedRoot = path.join(fixture.scratchRoot, "root-link"); + await symlink(fixture.evidenceRoot, linkedRoot, "dir"); + await assert.rejects( + () => fixture.persist({ scratchRoot: linkedRoot }), + /must not contain symlinks/u + ); + assert.equal((await lstat(linkedRoot)).isSymbolicLink(), true); + + const dynamics = path.join(fixture.scratchRoot, "dynamics"); + const artifactDirectory = path.join( + dynamics, + `sha256-${fixture.prepared.artifactSha256}` + ); + const externalDirectory = path.join(fixture.evidenceRoot, "caller-directory"); + const sentinel = path.join(externalDirectory, "sentinel"); + await mkdir(dynamics); + await mkdir(externalDirectory); + await writeFile(sentinel, "caller-owned\n"); + await symlink(externalDirectory, artifactDirectory, "dir"); + await assert.rejects(() => fixture.persist(), { code: "EEXIST" }); + assert.equal((await lstat(artifactDirectory)).isSymbolicLink(), true); + assert.equal(await readFile(sentinel, "utf8"), "caller-owned\n"); +}); + +test("scratch conflict preserves caller receipt and never publishes a partial pair", async (t) => { + const fixture = await createBuildLoadFixture(t); + const dynamics = path.join(fixture.scratchRoot, "dynamics"); + const receipt = path.join(dynamics, "build-receipt.json"); + await mkdir(dynamics); + await writeFile(receipt, "caller scratch sentinel\n"); + await assert.rejects(() => fixture.persist(), /target already exists/u); + assert.equal(await readFile(receipt, "utf8"), "caller scratch sentinel\n"); + assert.deepEqual(await readdir(dynamics), ["build-receipt.json"]); +}); + +test("artifact/receipt identity cannot be mixed across prepared builds", async (t) => { + const fixture = await createBuildLoadFixture(t); + const otherPrepared = preparedWithBody(fixture.prepared, "export const other = true;\n"); + await assert.rejects( + () => fixture.persist({ prepared: otherPrepared }), + /receipt bytes mismatch/u + ); + await assertMissing(path.join(fixture.scratchRoot, "dynamics")); +}); diff --git a/src/dynamics/buildLoad.ts b/src/dynamics/buildLoad.ts new file mode 100644 index 0000000..560c4e4 --- /dev/null +++ b/src/dynamics/buildLoad.ts @@ -0,0 +1,230 @@ +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { pathToFileURL } from "node:url"; +import { type PreparedDynamicsBuild } from "./build.js"; +import { + createDynamicsBuildReceipt, + type DynamicsBuildReceipt +} from "./buildReceipt.js"; +import { canonicalJson, sha256 } from "./buildIdentity.js"; +import { + assertDynamicsBuildPairDirectories, + assertDynamicsBuildRoot, + assertReadonlyBuildFile, + bytesEqual, + dynamicsBuildPairPaths, + stageDynamicsBuildPair, + type DynamicsBuildPairPaths, + type StagedDynamicsBuildPair +} from "./buildLoadFiles.js"; + +const fail = (message: string): never => { throw new Error(message); }; + +export interface PersistDynamicsBuildOptions { + readonly absoluteSimfilePath: string; + readonly evidenceRoot?: string; + readonly prepared: PreparedDynamicsBuild; + readonly receipt: unknown; + readonly scratchRoot: string; +} + +export interface DynamicsBuildArtifactEvidence { + readonly artifactPath: string; + readonly receiptPath: string; +} + +export interface DynamicsBuildArtifactLifecycle { + readonly artifactPath: string; + readonly evidence?: DynamicsBuildArtifactEvidence; + readonly receipt: DynamicsBuildReceipt; + readonly receiptPath: string; + cleanup(): Promise; + copyEvidence(): Promise; + importArtifact(): Promise>>; + verify(): Promise; +} + +const assertSeparatedRoots = (scratchRoot: string, evidenceRoot: string): void => { + const relativeEvidence = path.relative(scratchRoot, evidenceRoot); + const relativeScratch = path.relative(evidenceRoot, scratchRoot); + const outside = (relative: string): boolean => + path.isAbsolute(relative) + || relative === ".." + || relative.startsWith(`..${path.sep}`); + if ( + relativeEvidence === "" + || !outside(relativeEvidence) + || !outside(relativeScratch) + ) { + fail("scratch and evidence roots must not overlap"); + } +}; + +const assertReceipt = async ( + absoluteSimfilePath: string, + prepared: PreparedDynamicsBuild, + candidate: unknown +): Promise => { + const expected = await createDynamicsBuildReceipt(absoluteSimfilePath, prepared); + if (!candidate || typeof candidate !== "object" || Array.isArray(candidate)) { + fail("dynamics build receipt is missing or malformed"); + } + const receipt = candidate as Partial; + if (!Array.isArray(receipt.receiptBytes)) fail("dynamics build receipt bytes are missing or malformed"); + const receiptBytes = receipt.receiptBytes as readonly number[]; + if (typeof receipt.receiptSha256 !== "string") fail("dynamics build receipt SHA-256 is missing or malformed"); + if (!receipt.payload || typeof receipt.payload !== "object" || Array.isArray(receipt.payload)) { + fail("dynamics build receipt payload is missing or malformed"); + } + if (!bytesEqual(receiptBytes, expected.receiptBytes)) fail("dynamics build receipt bytes mismatch"); + if (sha256(Uint8Array.from(receiptBytes)) !== receipt.receiptSha256) { + fail("dynamics build receipt bytes/hash mismatch"); + } + if (receipt.receiptSha256 !== expected.receiptSha256) fail("dynamics build receipt SHA-256 mismatch"); + if (canonicalJson(receipt.payload) !== canonicalJson(expected.payload)) { + fail("dynamics build receipt payload mismatch"); + } + return expected; +}; + +const verifyIssuedReceipt = async ( + absoluteSimfilePath: string, + prepared: PreparedDynamicsBuild, + receipt: DynamicsBuildReceipt +): Promise => { + const reissued = await createDynamicsBuildReceipt(absoluteSimfilePath, prepared); + if ( + reissued.receiptSha256 !== receipt.receiptSha256 + || !bytesEqual(reissued.receiptBytes, receipt.receiptBytes) + || canonicalJson(reissued.payload) !== canonicalJson(receipt.payload) + ) { + fail("dynamics build receipt no longer matches current source authority"); + } +}; + +const verifyPair = async ( + paths: DynamicsBuildPairPaths, + absoluteSimfilePath: string, + prepared: PreparedDynamicsBuild, + receipt: DynamicsBuildReceipt +): Promise => { + await assertDynamicsBuildPairDirectories(paths); + await assertReadonlyBuildFile( + paths.artifactPath, + prepared.artifactBytes, + prepared.artifactSha256, + "persisted dynamics artifact" + ); + await assertReadonlyBuildFile( + paths.receiptPath, + receipt.receiptBytes, + receipt.receiptSha256, + "persisted dynamics build receipt" + ); + await verifyIssuedReceipt(absoluteSimfilePath, prepared, receipt); +}; + +const writePair = ( + paths: DynamicsBuildPairPaths, + prepared: PreparedDynamicsBuild, + receipt: DynamicsBuildReceipt +): Promise => + stageDynamicsBuildPair( + paths, + prepared.artifactBytes, + prepared.artifactSha256, + receipt.receiptBytes, + receipt.receiptSha256 + ); + +export const persistDynamicsBuild = async ( + options: PersistDynamicsBuildOptions +): Promise => { + const scratchRoot = await assertDynamicsBuildRoot(options.scratchRoot, "dynamics scratch root"); + const evidenceRoot = options.evidenceRoot === undefined + ? undefined + : await assertDynamicsBuildRoot(options.evidenceRoot, "dynamics evidence root"); + if (evidenceRoot) assertSeparatedRoots(scratchRoot, evidenceRoot); + const receipt = await assertReceipt(options.absoluteSimfilePath, options.prepared, options.receipt); + const expectedArtifactPath = `./dynamics/sha256-${options.prepared.artifactSha256}/provider.mjs`; + if ( + receipt.payload.artifact_sha256 !== options.prepared.artifactSha256 + || receipt.payload.artifact_path !== expectedArtifactPath + ) { + fail("dynamics build receipt has the wrong content address"); + } + + const scratch = dynamicsBuildPairPaths(scratchRoot, options.prepared.artifactSha256); + const evidencePaths = evidenceRoot + ? dynamicsBuildPairPaths(evidenceRoot, options.prepared.artifactSha256) + : undefined; + const scratchStage = await writePair(scratch, options.prepared, receipt); + try { + await verifyPair(scratch, options.absoluteSimfilePath, options.prepared, receipt); + } catch (error) { + await scratchStage.cleanup(); + throw error; + } + + let copiedEvidence: DynamicsBuildArtifactEvidence | undefined; + let cleanupCompleted = false; + let cleanupInFlight: Promise | undefined; + const cleanup = (): Promise => { + if (cleanupCompleted) return Promise.resolve(); + if (cleanupInFlight) return cleanupInFlight; + const attempt = (async (): Promise => { + await scratchStage.cleanup(); + cleanupCompleted = true; + })(); + cleanupInFlight = attempt; + attempt.then( + () => { if (cleanupInFlight === attempt) cleanupInFlight = undefined; }, + () => { if (cleanupInFlight === attempt) cleanupInFlight = undefined; } + ); + return attempt; + }; + const copyEvidence = async (): Promise => { + if (!evidencePaths) return undefined; + if (copiedEvidence) { + await verifyPair(evidencePaths, options.absoluteSimfilePath, options.prepared, receipt); + return copiedEvidence; + } + await verifyPair(scratch, options.absoluteSimfilePath, options.prepared, receipt); + let evidenceStage: StagedDynamicsBuildPair | undefined; + try { + evidenceStage = await writePair(evidencePaths, options.prepared, receipt); + await verifyPair(evidencePaths, options.absoluteSimfilePath, options.prepared, receipt); + } catch (error) { + await evidenceStage?.cleanup(); + throw error; + } + copiedEvidence = { + artifactPath: evidencePaths.artifactPath, + receiptPath: evidencePaths.receiptPath + }; + return copiedEvidence; + }; + const importArtifact = async (): Promise>> => { + try { + await verifyPair(scratch, options.absoluteSimfilePath, options.prepared, receipt); + await copyEvidence(); + const imported = await import(`${pathToFileURL(scratch.artifactPath).href}?simfile=${randomUUID()}`) as Readonly>; + await verifyPair(scratch, options.absoluteSimfilePath, options.prepared, receipt); + return imported; + } catch (error) { + await cleanup(); + throw error; + } + }; + + return { + artifactPath: scratch.artifactPath, + get evidence() { return copiedEvidence; }, + receipt, + receiptPath: scratch.receiptPath, + cleanup, + copyEvidence, + importArtifact, + verify: () => verifyPair(scratch, options.absoluteSimfilePath, options.prepared, receipt) + }; +}; diff --git a/src/dynamics/buildLoadFiles.ts b/src/dynamics/buildLoadFiles.ts new file mode 100644 index 0000000..f72116d --- /dev/null +++ b/src/dynamics/buildLoadFiles.ts @@ -0,0 +1,206 @@ +import { + chmod, + lstat, + mkdir, + open, + readFile, + realpath, + rename, + rm, + rmdir +} from "node:fs/promises"; +import path from "node:path"; +import { randomUUID } from "node:crypto"; +import { sha256 } from "./buildIdentity.js"; + +const fail = (message: string): never => { throw new Error(message); }; + +export interface DynamicsBuildPairPaths { + readonly artifactDirectory: string; + readonly artifactPath: string; + readonly dynamicsDirectory: string; + readonly receiptPath: string; +} + +export interface StagedDynamicsBuildPair { + readonly paths: DynamicsBuildPairPaths; + cleanup(): Promise; +} + +export const bytesEqual = (left: ArrayLike, right: ArrayLike): boolean => { + if (left.length !== right.length) return false; + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) return false; + } + return true; +}; + +export const assertDynamicsBuildRoot = async (candidate: string, label: string): Promise => { + if (!path.isAbsolute(candidate)) fail(`${label} must be absolute`); + if (path.resolve(candidate) !== candidate) fail(`${label} must be normalized`); + const observed = await realpath(candidate).catch(() => fail(`${label} must be an existing directory`)); + if (observed !== candidate) fail(`${label} must not contain symlinks`); + const metadata = await lstat(candidate); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) fail(`${label} must be a non-symlink directory`); + return candidate; +}; + +export const dynamicsBuildPairPaths = ( + root: string, + artifactSha256: string +): DynamicsBuildPairPaths => { + const dynamicsDirectory = path.join(root, "dynamics"); + const artifactDirectory = path.join(dynamicsDirectory, `sha256-${artifactSha256}`); + return { + dynamicsDirectory, + artifactDirectory, + artifactPath: path.join(artifactDirectory, "provider.mjs"), + receiptPath: path.join(dynamicsDirectory, "build-receipt.json") + }; +}; + +const assertDirectory = async (directory: string): Promise => { + const metadata = await lstat(directory); + if (metadata.isSymbolicLink() || !metadata.isDirectory()) { + fail(`dynamics artifact path is not a regular directory: ${directory}`); + } +}; + +const assertAbsent = async (fileName: string): Promise => { + try { + await lstat(fileName); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + fail(`dynamics artifact target already exists: ${fileName}`); +}; + +export const assertReadonlyBuildFile = async ( + fileName: string, + expected: readonly number[], + expectedSha256: string, + label: string +): Promise => { + const metadata = await lstat(fileName).catch(() => fail(`${label} is missing`)); + if (metadata.isSymbolicLink() || !metadata.isFile()) fail(`${label} must be a regular file`); + if ((metadata.mode & 0o222) !== 0) fail(`${label} must be read-only`); + const observed = await readFile(fileName); + if (!bytesEqual(observed, expected)) fail(`${label} bytes mismatch`); + if (sha256(observed) !== expectedSha256) fail(`${label} SHA-256 mismatch`); +}; + +const publishReadonlyFile = async ( + fileName: string, + bytes: readonly number[], + expectedSha256: string +): Promise => { + const temporary = path.join(path.dirname(fileName), `.${path.basename(fileName)}.${randomUUID()}.tmp`); + let temporaryCreated = false; + let targetCreated = false; + let handle: Awaited> | undefined; + try { + handle = await open(temporary, "wx", 0o600); + temporaryCreated = true; + await handle.writeFile(Uint8Array.from(bytes)); + await handle.sync(); + await handle.close(); + handle = undefined; + const staged = await readFile(temporary); + if (!bytesEqual(staged, bytes) || sha256(staged) !== expectedSha256) { + fail(`temporary dynamics artifact verification failed: ${fileName}`); + } + + const reservation = await open(fileName, "wx", 0o000); + targetCreated = true; + await reservation.close(); + await rename(temporary, fileName); + temporaryCreated = false; + await chmod(fileName, 0o444); + await assertReadonlyBuildFile(fileName, bytes, expectedSha256, "persisted dynamics artifact"); + } catch (error) { + if (handle) await handle.close().catch(() => undefined); + if (temporaryCreated) await rm(temporary, { force: true }).catch(() => undefined); + if (targetCreated) await rm(fileName, { force: true }).catch(() => undefined); + throw error; + } +}; + +export const stageDynamicsBuildPair = async ( + paths: DynamicsBuildPairPaths, + artifactBytes: readonly number[], + artifactSha256: string, + receiptBytes: readonly number[], + receiptSha256: string +): Promise => { + let dynamicsCreated = false; + let artifactDirectoryCreated = false; + let artifactCreated = false; + let receiptCreated = false; + let cleanupCompleted = false; + let cleanupInFlight: Promise | undefined; + const cleanup = (): Promise => { + if (cleanupCompleted) return Promise.resolve(); + if (cleanupInFlight) return cleanupInFlight; + const attempt = (async (): Promise => { + if (receiptCreated) { + await rm(paths.receiptPath, { force: true }); + receiptCreated = false; + } + if (artifactCreated) { + await rm(paths.artifactPath, { force: true }); + artifactCreated = false; + } + if (artifactDirectoryCreated) { + await rmdir(paths.artifactDirectory); + artifactDirectoryCreated = false; + } + if (dynamicsCreated) { + await rmdir(paths.dynamicsDirectory); + dynamicsCreated = false; + } + cleanupCompleted = true; + })(); + cleanupInFlight = attempt; + attempt.then( + () => { if (cleanupInFlight === attempt) cleanupInFlight = undefined; }, + () => { if (cleanupInFlight === attempt) cleanupInFlight = undefined; } + ); + return attempt; + }; + + try { + try { + await mkdir(paths.dynamicsDirectory, { mode: 0o700 }); + dynamicsCreated = true; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + await assertDirectory(paths.dynamicsDirectory); + } + await assertAbsent(paths.receiptPath); + await mkdir(paths.artifactDirectory, { mode: 0o700 }); + artifactDirectoryCreated = true; + await publishReadonlyFile(paths.artifactPath, artifactBytes, artifactSha256); + artifactCreated = true; + await publishReadonlyFile(paths.receiptPath, receiptBytes, receiptSha256); + receiptCreated = true; + return { paths, cleanup }; + } catch (error) { + try { + await cleanup(); + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + "dynamics artifact staging and owned cleanup both failed" + ); + } + throw error; + } +}; + +export const assertDynamicsBuildPairDirectories = async ( + paths: DynamicsBuildPairPaths +): Promise => { + await assertDirectory(paths.dynamicsDirectory); + await assertDirectory(paths.artifactDirectory); +}; diff --git a/src/dynamics/buildPackagePolicy.ts b/src/dynamics/buildPackagePolicy.ts new file mode 100644 index 0000000..e89ec01 --- /dev/null +++ b/src/dynamics/buildPackagePolicy.ts @@ -0,0 +1,131 @@ +import { lstat, readFile } from "node:fs/promises"; +import path from "node:path"; + +import { sha256 } from "./buildIdentity.js"; +import { DYNAMICS_BUILD_PREPARATION_POLICY } from "./buildInput.js"; + +export interface PackageIdentity { + readonly directory: string; + readonly manifestSha256: string; + readonly name: string; + readonly version: string; +} + +const packagePolicy = DYNAMICS_BUILD_PREPARATION_POLICY.package; +const packageName = new RegExp(packagePolicy.namePattern, "u"); +const packageVersion = new RegExp(packagePolicy.versionPattern, "u"); +const packageManifest = packagePolicy.manifestFileName; +const nodeModulesDirectory = packagePolicy.nodeModulesDirectory; +const scopePrefix = packagePolicy.scopePrefix; +const declarationExtensions = DYNAMICS_BUILD_PREPARATION_POLICY.source.declarationExtensions; + +interface OwnedPackageSource { + readonly fileName: string; + readonly identity?: PackageIdentity; +} +type PackageByteReader = (fileName: string) => Promise; + +export const isContained = (root: string, candidate: string): boolean => { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +}; + +/** Maps DefinitelyTyped package ownership back to the runtime package it describes. */ +export const runtimePackageNameForTypes = (name: string): string => { + if (!name.startsWith("@types/")) return name; + const encoded = name.slice("@types/".length); + const scoped = encoded.indexOf("__"); + return scoped < 0 + ? encoded + : `@${encoded.slice(0, scoped)}/${encoded.slice(scoped + 2)}`; +}; + +export const declarationBackedRuntimeRoots = ( + runtimeInputs: readonly string[], + sources: ReadonlyMap, + typeSources: readonly OwnedPackageSource[], + explicitProjectRoots: readonly string[] = [] +): ReadonlySet => { + const declaredPackages = new Set(typeSources + .filter((source) => + declarationExtensions.some((extension) => source.fileName.endsWith(extension)) + && source.identity) + .map((source) => runtimePackageNameForTypes((source.identity as PackageIdentity).name))); + const packageBacked = runtimeInputs.filter((fileName) => { + const identity = sources.get(fileName)?.identity; + return identity !== undefined && declaredPackages.has(identity.name); + }); + const declaredProjectRoots = new Set(explicitProjectRoots.map((fileName) => path.resolve(fileName))); + return new Set([...packageBacked, ...runtimeInputs.filter((fileName) => declaredProjectRoots.has(path.resolve(fileName)))]); +}; + +const manifestAt = async ( + directory: string, + required: boolean, + expectedName?: string, + readBytes: PackageByteReader = readFile +): Promise => { + const manifestPath = path.join(directory, packageManifest); + try { + const stat = await lstat(manifestPath); + if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`package manifest must be a regular file: ${manifestPath}`); + const bytes = await readBytes(manifestPath); + const manifest: unknown = JSON.parse(new TextDecoder().decode(bytes)); + if (!manifest || Array.isArray(manifest) || typeof manifest !== "object") throw new Error(`package manifest must be a JSON object: ${manifestPath}`); + const { name, version } = manifest as { name?: unknown; version?: unknown }; + if (typeof name !== "string" || !packageName.test(name) || typeof version !== "string" || !packageVersion.test(version)) { + throw new Error(`package manifest has an invalid package identity: ${manifestPath}`); + } + if (expectedName && name !== expectedName) throw new Error(`package manifest name does not own its node_modules path: ${manifestPath}`); + return { directory, manifestSha256: sha256(bytes), name, version }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" && !required) return undefined; + if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`package manifest is required: ${manifestPath}`); + if (error instanceof SyntaxError) throw new Error(`package manifest is not valid JSON: ${manifestPath}`); + throw error; + } +}; + +const nodeModulesRootFor = (fileName: string): string | undefined => { + for (let directory = path.dirname(fileName);;) { + if (path.basename(directory) === nodeModulesDirectory) return directory; + const parent = path.dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +}; + +const ownerFor = (fileName: string, root: string): Readonly<{ directory: string; name: string }> => { + const [first, second] = path.relative(root, fileName).split(path.sep); + if (!first || first === ".." || (first.startsWith(scopePrefix) && !second)) { + throw new Error(`reachable code has no owning package below ${root}: ${fileName}`); + } + return first.startsWith(scopePrefix) + ? { directory: path.join(root, first, second), name: `${first}/${second}` } + : { directory: path.join(root, first), name: first }; +}; + +/** Finds the nearest node_modules owner and rejects every invalid boundary. */ +export const nodeModulesPackageFor = async ( + fileName: string, + readBytes: PackageByteReader = readFile +): Promise => { + const root = nodeModulesRootFor(fileName); + if (!root) return undefined; + const owner = ownerFor(fileName, root); + return manifestAt(owner.directory, true, owner.name, readBytes); +}; + +/** Finds the nearest enclosing regular package for external sources. */ +export const enclosingPackageFor = async ( + fileName: string, + readBytes: PackageByteReader = readFile +): Promise => { + for (let directory = path.dirname(fileName);;) { + const identity = await manifestAt(directory, false, undefined, readBytes); + if (identity) return identity; + const parent = path.dirname(directory); + if (parent === directory) return undefined; + directory = parent; + } +}; diff --git a/src/dynamics/buildReceipt.test-helper.ts b/src/dynamics/buildReceipt.test-helper.ts new file mode 100644 index 0000000..174fdb0 --- /dev/null +++ b/src/dynamics/buildReceipt.test-helper.ts @@ -0,0 +1,298 @@ +import assert from "node:assert/strict"; +import { readFile, realpath, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { + compareUtf16, + createDynamicsClosureIdentity, + canonicalJson, + sha256, + type DynamicsBuildInputDescriptor +} from "./buildIdentity.js"; +import { DYNAMICS_BUILD_CONTRACT, DYNAMICS_BUILD_PREPARATION_POLICY } from "./buildInput.js"; +import { DYNAMICS_STATIC_CLOSURE_POLICY } from "./buildStaticPolicy.js"; +import { buildReceiptLock } from "./buildReceiptLock.js"; +import { createDynamicsBuildReceipt } from "./buildReceipt.js"; +import { + collectStringValues, + createBuildTestProject, + prepareBuild, + writeBuildFile, + removeBuildTestPaths, + type PreparedBuild +} from "./buildTestSupport.test-helper.js"; +import { + createLockFile, + createPackageManifest, + writeSourceFile +} from "./buildReceiptLock.test-helper.js"; + +export const DYNAMICS_BUILD_RECEIPT_SCHEMA = "simfile.dynamics-build-receipt.v1" as const; +export const closurePreparationPolicy = { + ...DYNAMICS_BUILD_PREPARATION_POLICY, + staticClosure: DYNAMICS_STATIC_CLOSURE_POLICY +}; +export const buildReceiptConfigDigest = sha256(canonicalJson({ + buildContract: DYNAMICS_BUILD_CONTRACT, + preparationPolicy: closurePreparationPolicy +})); + +export interface ReceiptFixture { + readonly absoluteSimfilePath: string; + readonly prepared: PreparedBuild; + readonly projectRoot: string; +} + +export interface PackageTypeFixture { + readonly packageAndTypePrepared: PreparedBuild; + readonly packageInput: DynamicsBuildInputDescriptor & { readonly kind: "package" }; + readonly typeOnlyInput: DynamicsBuildInputDescriptor & { readonly kind: "type-only" }; +} + +export interface ReceiptAuthoritySet { + readonly authority: Awaited>; + readonly lockInputs: readonly (DynamicsBuildInputDescriptor & { readonly kind: "package" | "type-only" })[]; +} + +export const normalizeByCanonical = (value: readonly T[]): string => + canonicalJson([...value].sort((left, right) => compareUtf16(canonicalJson(left as Record), canonicalJson(right as Record)))); + +export const normalizeByDescriptor = (value: readonly T[]): T[] => + [...value].sort((left, right) => compareUtf16(canonicalJson(left as Record), canonicalJson(right as Record))); + +export const assertDeepFrozen = (value: unknown, label: string): void => { + if (value === null || typeof value !== "object") return; + assert.equal(Object.isFrozen(value), true, `${label} is not frozen`); + if (Array.isArray(value)) { + value.forEach((child, index) => assertDeepFrozen(child, `${label}[${index}]`)); + return; + } + for (const [name, child] of Object.entries(value as Record)) { + assertDeepFrozen(child, `${label}.${name}`); + } +}; + +export const assertNoForbiddenText = (value: string, forbiddenRoots: readonly string[]): void => { + const lowered = value.toLowerCase(); + for (const root of forbiddenRoots) { + if (root === "") continue; + const candidate = path.resolve(root); + const portable = candidate.replaceAll(path.sep, "/"); + if (value.includes(root) || value.includes(candidate) || value.includes(portable)) { + assert.fail(`forbidden filesystem path: ${value}`); + } + } + + if (/[A-Za-z]:\\/u.test(value)) assert.fail(`windows path leak: ${value}`); + if (/\r/u.test(value)) assert.fail(`CRLF leak: ${value}`); + if (value.includes("?")) assert.fail(`query leak: ${value}`); + if (value.includes("#")) assert.fail(`fragment leak: ${value}`); + if (value.includes("\0")) assert.fail(`NUL leak: ${value}`); + if (/\u0000|[\x01-\x08\x0b\x0c\x0e-\x1f]/u.test(value)) assert.fail(`control leak: ${value}`); + if (/https?:\/\//u.test(value)) assert.fail(`URL leak: ${value}`); + if (/\bregistry\b|\bsecret\b|\btoken\b/u.test(lowered)) assert.fail(`marker leak: ${value}`); +}; + +export const scanForLeaks = ( + payload: unknown, + receiptBytes: readonly number[], + forbiddenRoots: readonly string[] +): void => { + const values = [ + ...collectStringValues(payload), + String.fromCharCode(...receiptBytes) + ]; + for (const value of values) { + assertNoForbiddenText(value, forbiddenRoots); + } +}; + +export const buildVersions = (prepared: PreparedBuild): { esbuild: string; typescript: string } => ({ + esbuild: prepared.closureDescriptor.esbuild_version as string, + typescript: prepared.closureDescriptor.typescript_version as string +}); + +export const reSealPrepared = ( + prepared: PreparedBuild, + inputs: readonly PreparedBuild["inputs"][number][], + closureDescriptor?: Readonly> +): PreparedBuild => { + const descriptor = closureDescriptor ?? (prepared.closureDescriptor as Readonly>); + const nextInputs = normalizeByDescriptor(inputs); + const nextClosure = createDynamicsClosureIdentity({ + buildContract: DYNAMICS_BUILD_CONTRACT, + entry: prepared.module, + esbuildVersion: descriptor.esbuild_version as string, + inputs: nextInputs, + preparationPolicy: closurePreparationPolicy, + typecheckMode: prepared.typecheckMode, + typescriptVersion: descriptor.typescript_version as string, + usedNodeBuiltins: normalizeByDescriptor(prepared.nodeExternals as readonly string[]) + }); + + const oldHeader = new TextEncoder().encode(`/* simfile-dynamics-closure-sha256:${prepared.closureSha256} */\n`); + const body = prepared.artifactBytes.slice(oldHeader.length); + const nextHeader = new TextEncoder().encode(nextClosure.header); + const artifactBytes = new Uint8Array(nextHeader.length + body.length); + artifactBytes.set(nextHeader); + artifactBytes.set(Uint8Array.from(body), nextHeader.length); + + return { + ...prepared, + artifactBytes: Array.from(artifactBytes), + artifactSha256: sha256(artifactBytes), + closureDescriptor: nextClosure.descriptor, + closureSha256: nextClosure.sha256, + inputs: nextInputs, + nodeExternals: normalizeByDescriptor(prepared.nodeExternals as readonly string[]) + }; +}; + +export const createSyntheticMjsFixture = async (): Promise => { + let project: Awaited> | undefined; + try { + const parent = await realpath(os.tmpdir()); + project = await createBuildTestProject(parent); + await writeBuildFile(project, "systems/provider.mjs", "export const value = 1;\n"); + const prepared = await prepareBuild(project, "./systems/provider.mjs"); + const projectInputs = prepared.inputs.filter((entry) => entry.kind === "project"); + const resealed = reSealPrepared(prepared, projectInputs); + return { + absoluteSimfilePath: project.simfilePath, + prepared: resealed, + projectRoot: project.directory + }; + } catch (error) { + if (project) await removeBuildTestPaths(project.directory); + throw error; + } +}; + +export const createPackageAndTypeFixture = async (): Promise => { + let project: Awaited> | undefined; + try { + const parent = await realpath(os.tmpdir()); + project = await createBuildTestProject(parent); + + await createPackageManifest(path.join(project.directory, "node_modules", "fixture-pkg"), "fixture-pkg", "1.2.3", { + type: "module", + main: "./index.ts" + }); + await writeSourceFile(path.join(project.directory, "node_modules", "fixture-pkg", "index.ts"), "export const fixture = 11;\n"); + await writeBuildFile(project, "systems/provider.ts", [ + 'import { fixture } from "fixture-pkg";', + "export const value = fixture;" + ].join("\n") + "\n"); + await writeSourceFile(path.join(project.directory, "package.json"), JSON.stringify({ + name: "fixture-project", + version: "1.0.0", + type: "module", + dependencies: { "fixture-pkg": "1.2.3" } + })); + + await createLockFile(project.directory, "fixture-project", "1.0.0", [{ path: "node_modules/fixture-pkg", version: "1.2.3" }], { + dependencies: { + "fixture-pkg": "1.2.3" + } + }); + + const prepared = await prepareBuild(project, "./systems/provider.ts"); + const versions = buildVersions(prepared); + const authority = await buildReceiptLock(project.simfilePath, [], versions.esbuild, versions.typescript); + + const packageInput = prepared.inputs.find((entry): entry is (DynamicsBuildInputDescriptor & { kind: "package" }) => + entry.kind === "package" && entry.package_name === "fixture-pkg" + ); + const projectInput = prepared.inputs.find((entry): entry is (DynamicsBuildInputDescriptor & { kind: "project" }) => + entry.kind === "project" + ); + + if (!packageInput) { + assert.fail("missing fixture package input"); + } + if (!projectInput) { + assert.fail("missing project input"); + } + + if (authority.toolchainAuthority.root_package_name !== "simfile") { + assert.fail("unexpected toolchain root package name"); + } + + const typeOnlyFiles = [ + "./src/dynamics/buildReceiptLock.ts", + "./src/dynamics/buildReceiptLockAuthority.ts" + ] as const; + const typeOnlyInputs = await Promise.all(typeOnlyFiles.map(async (candidate) => ({ + path: candidate, + sha256: sha256(await readFile(path.join(authority.absoluteToolchainRoot, candidate.slice(2)), "utf8")) + }))); + const typeOnlyInput: DynamicsBuildInputDescriptor & { readonly kind: "type-only" } = { + kind: "type-only", + files: normalizeByDescriptor(typeOnlyInputs), + manifest_sha256: authority.toolchainAuthority.root_package_sha256, + package_name: "simfile", + package_version: authority.toolchainAuthority.root_package_version, + surface: "dynamics" + }; + + const resealed = reSealPrepared(prepared, [projectInput, packageInput, typeOnlyInput]); + return { + absoluteSimfilePath: project.simfilePath, + packageAndTypePrepared: resealed, + prepared: resealed, + projectRoot: project.directory, + packageInput, + typeOnlyInput, + authority, + lockInputs: resealed.inputs.filter((entry) => entry.kind !== "project") as (DynamicsBuildInputDescriptor & { readonly kind: "package" | "type-only" })[] + }; + } catch (error) { + if (project) await removeBuildTestPaths(project.directory); + throw error; + } +}; + +export const assertProjectPostPrepareMutationRejected = async (): Promise => { + const fixture = await createSyntheticMjsFixture(); + try { + await writeFile(`${fixture.projectRoot}/systems/provider.mjs`, "export const value = 2;\n"); + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared), + /prepared project descriptor mismatch/i + ); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}; + +export const assertPackagePostPrepareMutationRejected = async (): Promise => { + const fixture = await createPackageAndTypeFixture(); + try { + await writeFile(`${fixture.projectRoot}/node_modules/fixture-pkg/index.ts`, "export const fixture = 12;\n"); + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared), + /missing package lock evidence|claim mismatch|package claim|source hash mismatch/i + ); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}; + +export const assertTypeOnlyPostPrepareMutationRejected = async (): Promise => { + const fixture = await createPackageAndTypeFixture(); + try { + const typeOnlyInput = fixture.prepared.inputs.find((entry) => entry.kind === "type-only"); + assert.ok(typeOnlyInput); + if (!typeOnlyInput) return; + const corrupt = reSealPrepared(fixture.prepared, fixture.prepared.inputs.map((entry) => entry.kind === "type-only" + ? { ...typeOnlyInput, files: [{ ...typeOnlyInput.files[0]!, sha256: "0".repeat(64) }, ...typeOnlyInput.files.slice(1)] } + : entry + )); + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, corrupt), + /type-only|source hash mismatch|claim/i + ); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}; diff --git a/src/dynamics/buildReceipt.test.ts b/src/dynamics/buildReceipt.test.ts new file mode 100644 index 0000000..6361d9b --- /dev/null +++ b/src/dynamics/buildReceipt.test.ts @@ -0,0 +1,255 @@ +import assert from "node:assert/strict"; +import { realpath, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; +import { canonicalJson, compareUtf16, createDynamicsClosureIdentity, sha256 } from "./buildIdentity.js"; +import { DYNAMICS_BUILD_CONTRACT } from "./buildInput.js"; +import { createDynamicsBuildReceipt, DYNAMICS_BUILD_RECEIPT_VERSION } from "./buildReceipt.js"; +import { assertDedupedLocks } from "./buildReceiptSourceEvidence.js"; +import { assertPackagePostPrepareMutationRejected, assertProjectPostPrepareMutationRejected, assertTypeOnlyPostPrepareMutationRejected, buildReceiptConfigDigest, assertDeepFrozen, assertNoForbiddenText, closurePreparationPolicy, createPackageAndTypeFixture, createSyntheticMjsFixture, reSealPrepared, scanForLeaks } from "./buildReceipt.test-helper.js"; +import { createBuildTestProject, prepareBuild, removeBuildTestPaths, writeBuildFile } from "./buildTestSupport.test-helper.js"; +import type { PreparedBuild } from "./buildTestSupport.test-helper.js"; +import { buildReceiptLock } from "./buildReceiptLock.js"; +import { resolveToolchainAuthorityFromAnchor } from "./buildReceiptLockAuthority.js"; +import { + createLockFile, + createSymlinkDirectory, + writeJson +} from "./buildReceiptLock.test-helper.js"; + +test("rejects non-canonical caller input ordering and type-only permutations", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, { + ...fixture.prepared, + inputs: [...fixture.prepared.inputs].reverse() + }), + /prepared\.inputs are not canonical/i + ); + + const typeOnly = fixture.prepared.inputs.find((entry) => entry.kind === "type-only"); + assert.equal(Boolean(typeOnly), true); + if (typeOnly) { + const reversedTypeOnly = reSealPrepared(fixture.prepared, fixture.prepared.inputs.map((entry) => entry.kind === "type-only" + ? { ...typeOnly, files: [...typeOnly.files].reverse() } + : entry + )); + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, reversedTypeOnly), + /prepared\.inputs\[\d+\]\.files\[0\]|non-canonical file order/i + ); + + const wrongSurface = reSealPrepared(fixture.prepared, fixture.prepared.inputs.map((entry) => { + if (entry.kind !== "type-only") return entry; + return { ...entry, surface: "runtime" } as unknown as PreparedBuild["inputs"][number]; + })); + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, wrongSurface), + /surface: expected dynamics/i + ); + } + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("rejects invalid builtins from allowlist", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + const invalidBuiltins = [...fixture.prepared.nodeExternals, "node:child_process"]; + const resealed = reSealPrepared({ + ...fixture.prepared, + nodeExternals: invalidBuiltins + }, fixture.prepared.inputs, { + ...fixture.prepared.closureDescriptor, + used_node_builtins: invalidBuiltins + }); + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, resealed), + /unsupported node builtin|node builtin/i + ); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("rejects same-path conflicts across prepared descriptors", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + const packageInput = fixture.prepared.inputs.find((entry) => entry.kind === "package"); + assert.equal(Boolean(packageInput), true); + if (packageInput) { + const samePathConflict = reSealPrepared(fixture.prepared, [ + ...fixture.prepared.inputs, + { ...packageInput, sha256: "0".repeat(64) } + ]); + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, samePathConflict), + /same-path conflicting descriptor/i + ); + } + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("preserves B62 deduped lock ordering and uniqueness", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + const result = await createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared); + const authority = await buildReceiptLock( + fixture.absoluteSimfilePath, + fixture.prepared.inputs.filter((entry) => entry.kind !== "project"), + fixture.prepared.closureDescriptor.esbuild_version as string, + fixture.prepared.closureDescriptor.typescript_version as string + ); + const orderedByLockSha = [...authority.dedupedLocks].sort((left, right) => compareUtf16(left.lock_sha256, right.lock_sha256)); + assert.deepEqual(authority.dedupedLocks, orderedByLockSha); + assert.deepEqual(result.payload.deduped_locks, authority.dedupedLocks); + assert.deepEqual(assertDedupedLocks(authority.dedupedLocks), authority.dedupedLocks); + if (authority.dedupedLocks.length > 1) { + assert.throws( + () => assertDedupedLocks([...authority.dedupedLocks].reverse()), + /not ordered by lock_sha256|ambiguous/ + ); + } + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("requires exactly one runtime project descriptor", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + const projectInput = fixture.prepared.inputs.find((entry) => entry.kind === "project"); + assert.ok(projectInput); + if (!projectInput) return; + + const extraSource = "export const extra = 1;\n"; + await writeFile(`${fixture.projectRoot}/systems/extra.ts`, extraSource); + const withExtraProject = reSealPrepared(fixture.prepared, [...fixture.prepared.inputs, { + kind: "project", + modes: ["runtime"], + path: "./systems/extra.ts", + sha256: sha256(extraSource) + }]); + await createDynamicsBuildReceipt(fixture.absoluteSimfilePath, withExtraProject); + + const projectCases: readonly [readonly PreparedBuild["inputs"][number][], RegExp][] = [ + [fixture.prepared.inputs.filter((entry) => entry.kind !== "project"), /exactly one project descriptor/i], + [[...fixture.prepared.inputs, { ...projectInput, sha256: `${projectInput.sha256.slice(0, 63)}f` }], /same-path conflicting descriptor/i], + [fixture.prepared.inputs.map((entry) => entry.kind === "project" ? { ...entry, modes: ["type-only"] } : entry), /runtime mode/i] + ]; + for (const [inputs, pattern] of projectCases) { + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, reSealPrepared(fixture.prepared, inputs)), + pattern + ); + } + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("rejects closure and config mismatches and stale digests", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + const closureMutations = [ + [{ ...fixture.prepared.closureDescriptor, used_node_builtins: [...fixture.prepared.closureDescriptor.used_node_builtins as readonly string[], "node:buffer"] }, /used_node_builtins|mismatch|closure/i], + [{ ...fixture.prepared.closureDescriptor, typescript_version: "0.0.0" }, /toolchain|types|closure/i], + [{ ...fixture.prepared.closureDescriptor, build_contract: {} }, /build_contract|canonical/i] + ] as const; + for (const [descriptor, pattern] of closureMutations) { + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, { ...fixture.prepared, closureDescriptor: descriptor }), + pattern + ); + } + + const staleMutations: ReadonlyArray<[PreparedBuild, RegExp]> = [ + [{ ...fixture.prepared, artifactSha256: "0".repeat(64) }, /prepared artifact SHA mismatch/i], + [{ ...fixture.prepared, closureSha256: "0".repeat(64) }, /prepared closure SHA mismatch/i] + ]; + for (const [prepared, pattern] of staleMutations) { + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, prepared), + pattern + ); + } + + const headerCorrupted = [...fixture.prepared.artifactBytes]; + const first = headerCorrupted[0]; + if (first !== undefined) headerCorrupted[0] = first === 10 ? 11 : 10; + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, { + ...fixture.prepared, + artifactBytes: headerCorrupted, + artifactSha256: sha256(Uint8Array.from(headerCorrupted)) + }), + /artifact header mismatch|header/i + ); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("rejects project post-prepare mutation independently", assertProjectPostPrepareMutationRejected); +test("rejects package post-prepare mutation independently", assertPackagePostPrepareMutationRejected); +test("rejects type-only post-prepare mutation independently", assertTypeOnlyPostPrepareMutationRejected); + +test("rejects hostile serialized package identity metadata", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + const packageInput = fixture.prepared.inputs.find((entry) => entry.kind === "package"); + assert.ok(packageInput); + if (!packageInput) return; + const hostileMutations: readonly [PreparedBuild["inputs"][number], RegExp][] = [ + [{ ...packageInput, package_name: "project?secret" }, /package_name|invalid npm package/i], + [{ ...packageInput, package_version: "1.0.0#token" }, /package_version|invalid npm package/i], + [{ ...packageInput, package_name: "https://example.com/pkg" }, /package_name|invalid npm package/i], + [{ ...packageInput, package_name: `fixture-pkg${"\u0007"}` }, /package_name|control/i] + ]; + for (const [patchedPackage, pattern] of hostileMutations) { + const hostile = reSealPrepared( + fixture.prepared, + fixture.prepared.inputs.map((entry) => entry.kind === "package" ? patchedPackage : entry) + ); + await assert.rejects(() => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, hostile), pattern); + } + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("scans receipt bytes and payload for root/registry/secret/URL/query/fragment/control leakage", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + const result = await createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared); + const authority = fixture.authority; + const forbiddenRoots = [ + fixture.projectRoot, + authority.absoluteToolchainRoot, + authority.absoluteProjectRoot, + authority.toolchainAuthority.absoluteLockPath, + authority.projectAuthority.absoluteLockPath ?? "" + ]; + + scanForLeaks(result.payload, result.receiptBytes, forbiddenRoots); + + const receiptText = String.fromCharCode(...result.receiptBytes); + assert.equal(/https?:\/\//u.test(receiptText), false); + assert.equal(receiptText.includes("?"), false); + assert.equal(receiptText.includes("#"), false); + for (const value of [receiptText]) { + assertNoForbiddenText(value, [fixture.projectRoot]); + assert.equal(value.includes("registry"), false); + assert.equal(value.includes("secret"), false); + assert.equal(value.includes("token"), false); + } + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); diff --git a/src/dynamics/buildReceipt.ts b/src/dynamics/buildReceipt.ts new file mode 100644 index 0000000..f54266a --- /dev/null +++ b/src/dynamics/buildReceipt.ts @@ -0,0 +1,192 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { + canonicalJson, + createDynamicsClosureIdentity, + type DynamicsBuildInputDescriptor, + deepFreeze, + sha256 +} from "./buildIdentity.js"; +import { DYNAMICS_BUILD_CONTRACT } from "./buildInput.js"; +import { + buildReceiptLock, + type DynamicsReceiptLockDeduplicatedLock, + type DynamicsReceiptLockPortableRecord, + type DynamicsReceiptLockToolIdentity, + type DynamicsReceiptSelfLinkEntry +} from "./buildReceiptLock.js"; +import { validateStaticSourcePath } from "./buildStaticPolicy.js"; +import { resolveSimfileProjectRoot } from "./buildReceiptLockFiles.js"; +import { type PreparedDynamicsBuild } from "./build.js"; +import { + buildReceiptConfigDigest, + closurePreparationPolicy, + normalizePreparedBuild, + type NormalizedPreparedBuild +} from "./buildReceiptValidation.js"; +import { + assertCanonicalClaims, + assertDedupedLocks, + assertPortableClaims, + assertSourceClaims, + assertToolset +} from "./buildReceiptSourceEvidence.js"; +import { assertCanonicalSelfLinkEntries } from "./buildReceiptSelfLinks.js"; + +const fail = (message: string): never => { throw new Error(message); }; + +export const DYNAMICS_BUILD_RECEIPT_VERSION = "simfile.dynamics-build-receipt.v1" as const; + +export interface DynamicsBuildReceiptRuntimeIdentity { + readonly platform: string; + readonly arch: string; + readonly node: string; + readonly v8: string; +} + +export interface DynamicsBuildReceiptPayload { + readonly schema: typeof DYNAMICS_BUILD_RECEIPT_VERSION; + readonly module: string; + readonly source_graph: readonly DynamicsBuildInputDescriptor[]; + readonly source_graph_sha256: string; + readonly build_config_sha256: string; + readonly closure_header: string; + readonly closure_sha256: string; + readonly used_node_externals: readonly string[]; + readonly runtime_identity: DynamicsBuildReceiptRuntimeIdentity; + readonly artifact_sha256: string; + readonly artifact_path: string; + readonly build_tools: readonly DynamicsReceiptLockToolIdentity[]; + readonly deduped_locks: readonly DynamicsReceiptLockDeduplicatedLock[]; + readonly portable_claims: readonly DynamicsReceiptLockPortableRecord[]; + readonly self_link_entries: readonly DynamicsReceiptSelfLinkEntry[]; +} + +export interface DynamicsBuildReceipt { + readonly payload: DynamicsBuildReceiptPayload; + readonly receiptBytes: readonly number[]; + readonly receiptSha256: string; +} + +const canonicalSourceGraphDigest = (sourceGraph: readonly DynamicsBuildInputDescriptor[]): string => { + return sha256(canonicalJson(sourceGraph)); +}; + +const assertRuntimeProject = ( + inputs: readonly DynamicsBuildInputDescriptor[], + entry: string +): DynamicsBuildInputDescriptor & { readonly kind: "project" } => { + const projects = inputs.filter((input): input is DynamicsBuildInputDescriptor & { readonly kind: "project" } => + input.kind === "project" + && input.path === entry + && input.modes.includes("runtime") + ); + if (projects.length !== 1) { + fail("prepared.inputs must include exactly one project descriptor matching prepared module with runtime mode"); + } + const [project] = projects; + if (project === undefined) fail("prepared.project missing"); + return project; +}; + +export const createDynamicsBuildReceipt = async ( + absoluteSimfilePath: string, + prepared: PreparedDynamicsBuild +): Promise => { + if (!path.isAbsolute(absoluteSimfilePath)) fail("prepared simfile path must be absolute"); + const normalized = normalizePreparedBuild(prepared); + + const projectRoot = await resolveSimfileProjectRoot(absoluteSimfilePath); + for (const input of normalized.inputs) { + if (input.kind !== "project") continue; + const absolute = await validateStaticSourcePath( + path.join(projectRoot, input.path.slice(2)), + projectRoot + ); + const observed = sha256(await readFile(absolute)); + if (observed !== input.sha256) fail(`prepared project descriptor mismatch: ${input.path}`); + } + + const expectedClosure = createDynamicsClosureIdentity({ + buildContract: DYNAMICS_BUILD_CONTRACT, + entry: normalized.closure.entry, + esbuildVersion: normalized.closure.esbuildVersion, + inputs: normalized.closure.inputs, + preparationPolicy: closurePreparationPolicy, + typecheckMode: normalized.closure.preparedTypecheckMode, + typescriptVersion: normalized.closure.typescriptVersion, + usedNodeBuiltins: normalized.closure.usedNodeBuiltins + }); + + if (canonicalJson(expectedClosure.descriptor) !== canonicalJson(prepared.closureDescriptor)) { + fail("prepared.closureDescriptor mismatch"); + } + if (expectedClosure.sha256 !== normalized.closureSha256) { + fail("prepared closure SHA mismatch"); + } + assertRuntimeProject(normalized.inputs, normalized.closure.entry); + + const closureHeaderBytes = new TextEncoder().encode(expectedClosure.header); + if (normalized.artifactBytes.length < closureHeaderBytes.length) fail("artifact header truncated"); + for (let index = 0; index < closureHeaderBytes.length; index += 1) { + if (normalized.artifactBytes[index] !== closureHeaderBytes[index]) fail("artifact header mismatch"); + } + if (closureHeaderBytes[closureHeaderBytes.length - 1] !== 10) fail("artifact header is not LF-terminated"); + + const lockInputs = normalized.inputs.filter((input): input is typeof normalized.inputs[number] & { kind: "package" | "type-only" } => + input.kind === "package" || input.kind === "type-only" + ); + + const lockAuthority = await buildReceiptLock( + absoluteSimfilePath, + lockInputs, + normalized.closure.esbuildVersion, + normalized.closure.typescriptVersion + ); + + const buildTools = assertToolset( + lockAuthority.toolchainAuthority.tool_identities, + normalized.closure.esbuildVersion, + normalized.closure.typescriptVersion + ); + + const portableClaims = assertCanonicalClaims(lockAuthority.portableClaims); + assertPortableClaims(portableClaims); + const dedupedLocks = assertDedupedLocks(lockAuthority.dedupedLocks); + const selfLinkEntries = assertCanonicalSelfLinkEntries(lockAuthority.selfLinkEntries); + assertSourceClaims(normalized.inputs, portableClaims, lockInputs, buildTools); + + const sourceGraph = deepFreeze([...normalized.inputs]); + const payload: DynamicsBuildReceiptPayload = deepFreeze({ + schema: DYNAMICS_BUILD_RECEIPT_VERSION, + module: normalized.closure.entry, + source_graph: sourceGraph, + source_graph_sha256: canonicalSourceGraphDigest(sourceGraph), + build_config_sha256: buildReceiptConfigDigest, + closure_header: expectedClosure.header, + closure_sha256: expectedClosure.sha256, + used_node_externals: normalized.nodeBuiltins, + runtime_identity: deepFreeze({ + platform: process.platform, + arch: process.arch, + node: process.versions.node, + v8: process.versions.v8 + }), + artifact_sha256: normalized.artifactSha256, + artifact_path: `./dynamics/sha256-${normalized.artifactSha256}/provider.mjs`, + build_tools: buildTools, + deduped_locks: dedupedLocks, + portable_claims: portableClaims, + self_link_entries: selfLinkEntries + }); + + const payloadBytes = new TextEncoder().encode(`${canonicalJson(payload)}\n`); + const receiptBytes = deepFreeze(Array.from(payloadBytes)); + const receiptSha256 = sha256(Uint8Array.from(receiptBytes)); + + return deepFreeze({ + payload, + receiptBytes, + receiptSha256 + }); +}; diff --git a/src/dynamics/buildReceiptCreation.test.ts b/src/dynamics/buildReceiptCreation.test.ts new file mode 100644 index 0000000..e7dd75e --- /dev/null +++ b/src/dynamics/buildReceiptCreation.test.ts @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import { realpath } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { canonicalJson, createDynamicsClosureIdentity, sha256 } from "./buildIdentity.js"; +import { DYNAMICS_BUILD_CONTRACT } from "./buildInput.js"; +import { createDynamicsBuildReceipt, DYNAMICS_BUILD_RECEIPT_VERSION } from "./buildReceipt.js"; +import { + assertDeepFrozen, + buildReceiptConfigDigest, + closurePreparationPolicy, + createPackageAndTypeFixture, + createSyntheticMjsFixture, + reSealPrepared, +} from "./buildReceipt.test-helper.js"; +import { buildReceiptLock } from "./buildReceiptLock.js"; +import { resolveToolchainAuthorityFromAnchor } from "./buildReceiptLockAuthority.js"; +import { + createLockFile, + createSymlinkDirectory, + writeJson, +} from "./buildReceiptLock.test-helper.js"; +import { + createBuildTestProject, + prepareBuild, + removeBuildTestPaths, + writeBuildFile, +} from "./buildTestSupport.test-helper.js"; + +const assertReceiptBytes = (left: readonly number[], right: readonly number[]): void => { + assert.equal(left.length, right.length); + for (let index = 0; index < left.length; index += 1) { + if (left[index] !== right[index]) assert.fail(`receipt byte mismatch @${index}`); + } +}; + +test("createDynamicsBuildReceipt API is issuer-only", () => { + assert.equal(createDynamicsBuildReceipt.length, 2); +}); + +test("creates deterministic receipt for synthetic .mjs project", async () => { + const fixture = await createSyntheticMjsFixture(); + try { + const result = await createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared); + const expectedClosure = createDynamicsClosureIdentity({ + buildContract: DYNAMICS_BUILD_CONTRACT, + entry: fixture.prepared.module, + esbuildVersion: fixture.prepared.closureDescriptor.esbuild_version as string, + inputs: fixture.prepared.inputs, + preparationPolicy: closurePreparationPolicy, + typecheckMode: fixture.prepared.typecheckMode, + typescriptVersion: fixture.prepared.closureDescriptor.typescript_version as string, + usedNodeBuiltins: fixture.prepared.nodeExternals, + }); + assert.equal(result.payload.schema, DYNAMICS_BUILD_RECEIPT_VERSION); + assert.equal((result.payload as { version?: string }).version, undefined); + assert.equal((result.payload as { closure_descriptor?: object }).closure_descriptor, undefined); + assert.equal(result.payload.module, fixture.prepared.module); + assert.deepEqual(result.payload.source_graph, JSON.parse(canonicalJson(fixture.prepared.inputs))); + assert.deepEqual(result.payload.source_graph_sha256, sha256(canonicalJson(result.payload.source_graph))); + assert.equal(result.payload.build_config_sha256, buildReceiptConfigDigest); + assert.equal(result.payload.closure_header, expectedClosure.header); + assert.equal(result.payload.closure_sha256, expectedClosure.sha256); + assert.deepEqual(result.payload.used_node_externals, fixture.prepared.nodeExternals); + assert.deepEqual(result.payload.runtime_identity, { + platform: process.platform, arch: process.arch, node: process.versions.node, v8: process.versions.v8, + }); + assert.equal(result.payload.artifact_sha256, fixture.prepared.artifactSha256); + assert.equal(result.payload.artifact_path, + `./dynamics/sha256-${fixture.prepared.artifactSha256}/provider.mjs`); + assert.deepEqual(result.payload.self_link_entries, []); + assert.equal(Object.isFrozen(result.payload.self_link_entries), true); + const expectedBytes = new TextEncoder().encode(`${canonicalJson(result.payload)}\n`); + assertReceiptBytes(result.receiptBytes, Array.from(expectedBytes)); + assert.equal(Buffer.from(result.receiptBytes).toString("utf8").includes("\"self_link_entries\":[]"), true); + assert.equal(result.receiptBytes[result.receiptBytes.length - 1], 10); + assert.equal(result.receiptSha256, sha256(Uint8Array.from(result.receiptBytes))); + assertDeepFrozen(result, "result"); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("serializes a canonical project self-link without checkout disclosure", async () => { + const fixture = await createSyntheticMjsFixture(); + try { + const esbuildVersion = fixture.prepared.closureDescriptor.esbuild_version as string; + const typescriptVersion = fixture.prepared.closureDescriptor.typescript_version as string; + const toolchain = await resolveToolchainAuthorityFromAnchor( + fileURLToPath(import.meta.url), esbuildVersion, typescriptVersion, + ); + const resolved = path.relative(fixture.projectRoot, toolchain.absoluteRoot).split(path.sep).join("/"); + await writeJson(path.join(fixture.projectRoot, "package.json"), { + name: "fixture-project", version: "1.0.0", dependencies: { simfile: `file:${resolved}` }, + }); + await createLockFile(fixture.projectRoot, "fixture-project", "1.0.0", [ + { path: resolved, version: toolchain.root_package_version }, + { path: "node_modules/simfile", resolved, link: true }, + ], { dependencies: { simfile: `file:${resolved}` } }); + await createSymlinkDirectory( + toolchain.absoluteRoot, path.join(fixture.projectRoot, "node_modules", "simfile"), + ); + const authority = await buildReceiptLock( + fixture.absoluteSimfilePath, [], esbuildVersion, typescriptVersion, + ); + const first = await createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared); + const second = await createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared); + assert.deepEqual(first.payload.self_link_entries, authority.selfLinkEntries); + assert.equal(first.payload.self_link_entries.length, 1); + assertReceiptBytes(first.receiptBytes, second.receiptBytes); + assertDeepFrozen(first.payload.self_link_entries, "self_link_entries"); + const receiptText = Buffer.from(first.receiptBytes).toString("utf8"); + assert.equal(receiptText.includes(resolved), false); + assert.equal(receiptText.includes(toolchain.absoluteRoot), false); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("creates receipt directly from prepared TypeScript build with package-root type paths", async () => { + const project = await createBuildTestProject(await realpath(os.tmpdir())); + try { + await writeBuildFile(project, "systems/provider.ts", [ + "import type { DynamicsJsonValue } from 'simfile/dynamics';", + "export const value: DynamicsJsonValue = 1;", + ].join("\n")); + const prepared = await prepareBuild(project, "./systems/provider.ts"); + const typeSurface = prepared.inputs.find((input) => input.kind === "type-only"); + assert.ok(typeSurface); + assert.equal(typeSurface.files.length > 0, true); + assert.equal(typeSurface.files.every((file) => file.path.startsWith("./src/")), true); + assert.deepEqual(typeSurface.files.filter((file) => !file.path.startsWith("./src/dynamics/")) + .map((file) => file.path), [ + "./src/kernel/duration.ts", "./src/runtime/clock.ts", "./src/schema/identifier.ts", + "./src/schema/model.ts", + ...["authority", "definition", "index", "invoke", "observation", "own-data", + "recommendation", "rejection", "schema-value", "schema", "synchrony", "types"] + .map((file) => `./src/world-surface/${file}.ts`), + ...["act", "actEnvelope", "actTypes", "actionJournal", "actionJournalInspection", + "actionJournalSnapshot", "actionRefusalJournal", "actionResult", "actionResultLedger", + "actionResultLedgerInspection", "actionResultLedgerSnapshot", "actionResultProjection", + "actionResults", "addresses", "affordances", "capabilityManifest", "checkpoint", + "checkpointDynamicsSnapshot", "checkpointRelations", "checkpointRestore", + "checkpointRuntime", "checkpointSnapshot", "clockAuthority", "controllerAuthority", + "decisionClaim", "decisionRegistry", "decisionRegistryInput", "decisionRegistrySnapshot", + "decisionResultReadAdmission", "grantAttestation", "grantComposition", "grants", + "hostileJson", "index", "ledger", "observe", "readLedgerSnapshot", "requestLedger", + "requestLedgerInspection", "requestLedgerSnapshot", "runtime", "runtimeComposition"] + .map((file) => `./src/world/${file}.ts`), + ]); + assert.equal(prepared.inputs.some((input) => + input.kind === "package" && input.package_name === "simfile"), false); + const receipt = await createDynamicsBuildReceipt(project.simfilePath, prepared); + assert.deepEqual(receipt.payload.source_graph, prepared.inputs); + assert.equal(receipt.payload.closure_sha256, prepared.closureSha256); + } finally { + await removeBuildTestPaths(project.directory); + } +}); + +test("creates package evidence and one-file type-only classification", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + const baseline = await createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared); + const packageClaims = baseline.payload.portable_claims.filter((claim) => + claim.tool_identities.length === 0); + const oneFileTypeClaims = baseline.payload.portable_claims.filter((claim) => + claim.tool_identities.length === 2); + assert.equal(packageClaims.length, 1); + assert.equal(oneFileTypeClaims.length, 1); + const packageClaim = packageClaims[0]!; + assert.equal(packageClaim.package_name, fixture.packageInput.package_name); + assert.equal(packageClaim.package_version, fixture.packageInput.package_version); + assert.equal(packageClaim.package_manifest_sha256, fixture.packageInput.manifest_sha256); + assert.deepEqual(packageClaim.source_digests, [{ + path: fixture.packageInput.package_path, sha256: fixture.packageInput.sha256, + }]); + assert.equal(packageClaim.tool_identities.length, 0); + const oneFileTypeInput = { + ...fixture.typeOnlyInput, files: [...fixture.typeOnlyInput.files.slice(0, 1)], + }; + const oneFilePrepared = reSealPrepared( + fixture.prepared, + fixture.prepared.inputs.map((entry) => entry.kind === "type-only" ? oneFileTypeInput : entry), + ); + const result = await createDynamicsBuildReceipt(fixture.absoluteSimfilePath, oneFilePrepared); + const claims = result.payload.portable_claims.filter((claim) => claim.tool_identities.length === 2); + assert.equal(claims.length, 1); + assert.equal(claims[0]!.source_digests.length, 1); + assert.equal(claims[0]!.tool_identities.length, 2); + assert.equal(claims[0]!.package_name, fixture.typeOnlyInput.package_name); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("requires redundant typecheck mode equality", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + await assert.rejects(() => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, { + ...fixture.prepared, + typecheckMode: fixture.prepared.typecheckMode === "none" ? "typescript" : "none", + }), /prepared\.typecheckMode mismatch/i); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("requires type-only claims to reuse authoritative buildTools", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + const result = await createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared); + const claims = result.payload.portable_claims.filter((claim) => claim.tool_identities.length === 2); + assert.equal(claims.length, 1); + assert.equal(canonicalJson(claims[0]!.tool_identities), canonicalJson(result.payload.build_tools)); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); diff --git a/src/dynamics/buildReceiptHostile.test.ts b/src/dynamics/buildReceiptHostile.test.ts new file mode 100644 index 0000000..56c0644 --- /dev/null +++ b/src/dynamics/buildReceiptHostile.test.ts @@ -0,0 +1,389 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, realpath, readFile, rm, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import os from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createDynamicsBuildReceipt, type DynamicsBuildReceipt } from "./buildReceipt.js"; +import { createBuildTestProject, removeBuildTestPaths, writeBuildFile } from "./buildTestSupport.test-helper.js"; +import { createLockFile, createPackageManifest, writeSourceFile } from "./buildReceiptLock.test-helper.js"; +import { + assertNoForbiddenText, + createPackageAndTypeFixture +} from "./buildReceipt.test-helper.js"; +import { sha256 } from "./buildIdentity.js"; + +type LocaleProcessLocale = "en-US" | "sv-SE"; + +interface LocaleChildResult { + readonly childPid: number; + readonly requestedLocale: LocaleProcessLocale; + readonly resolvedLocale: string; + readonly controlOrder: readonly string[]; + readonly localeEnvironment: { + readonly lang: string | undefined; + readonly lcAll: string | undefined; + }; + readonly receipt: DynamicsBuildReceipt; +} + +const LOCALE_ENV: Record = { + "en-US": "en_US.UTF-8", + "sv-SE": "sv_SE.UTF-8" +} as const; + +const LOCALE_CONTROL_ORDER: Record = { + "en-US": ["ä", "z"], + "sv-SE": ["z", "ä"] +} as const; + +const readStreamText = async (stream: NodeJS.ReadableStream): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +}; + +const runLocaleReceiptChild = async (projectDirectory: string, locale: LocaleProcessLocale): Promise => { + const expectedLocaleEnv = LOCALE_ENV[locale]; + const buildModuleUrl = new URL("./build.js", import.meta.url).href; + const receiptModuleUrl = new URL("./buildReceipt.js", import.meta.url).href; + const script = [ + "import path from \"node:path\";", + `import { prepareDynamicsBuild } from ${JSON.stringify(buildModuleUrl)};`, + `import { createDynamicsBuildReceipt } from ${JSON.stringify(receiptModuleUrl)};`, + `const projectDirectory = ${JSON.stringify(projectDirectory)};`, + "const locale = process.env.SIMFILE_TEST_LOCALE ?? \"en-US\";", + "const localeEnvironment = {", + ` \"en-US\": ${JSON.stringify(LOCALE_ENV["en-US"])},`, + ` \"sv-SE\": ${JSON.stringify(LOCALE_ENV["sv-SE"])}`, + "};", + "const requestedLocale = locale.replace(\"_\", \"-\");", + "const expectedLocale = localeEnvironment[requestedLocale];", + "if (!expectedLocale) {", + " throw new Error(`unsupported locale ${requestedLocale}`);", + "}", + "const lang = process.env.LANG;", + "const lcAll = process.env.LC_ALL;", + "if (lang !== expectedLocale || lcAll !== expectedLocale) {", + " throw new Error(`child locale mismatch: LANG=${lang} LC_ALL=${lcAll} expected=${expectedLocale}`);", + "}", + "const collator = new Intl.Collator(undefined, { usage: \"sort\", sensitivity: \"variant\" });", + "const controlOrder = [\"ä\", \"z\"];", + "controlOrder.sort(collator.compare);", + "const resolvedLocale = collator.resolvedOptions().locale;", + "if (!resolvedLocale.toLowerCase().startsWith(requestedLocale.toLowerCase())) {", + " throw new Error(`child locale mismatch: ${requestedLocale} -> ${resolvedLocale}`);", + "}", + "const prepared = await prepareDynamicsBuild(path.join(projectDirectory, \"Simfile\"), \"./provider.ts\");", + "const receipt = await createDynamicsBuildReceipt(path.join(projectDirectory, \"Simfile\"), prepared);", + "process.stdout.write(JSON.stringify({", + " childPid: process.pid,", + " requestedLocale,", + " resolvedLocale,", + " controlOrder,", + " localeEnvironment: { lang, lcAll },", + " receipt", + "}));" + ].join("\n"); + + const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", script], { + env: { + ...process.env, + LANG: expectedLocaleEnv, + LC_ALL: expectedLocaleEnv, + SIMFILE_TEST_LOCALE: locale + }, + stdio: ["ignore", "pipe", "pipe"] + }); + + const [stdout, stderr] = await Promise.all([readStreamText(child.stdout), readStreamText(child.stderr)]); + const exitCode = await new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code) => resolve(code ?? 0)); + }); + if (exitCode !== 0) throw new Error(`locale receipt child failed (${exitCode}): ${stderr}`); + try { + return JSON.parse(stdout) as LocaleChildResult; + } catch { + throw new Error(`locale child returned invalid JSON: ${stdout}`); + } +}; + +const createLocaleReceiptFixture = async (parent: string) => { + const project = await createBuildTestProject(parent); + const projectManifest = path.join(project.directory, "package.json"); + await writeFile(projectManifest, JSON.stringify({ + name: "fixture-project", + version: "1.0.0", + type: "module", + dependencies: { "fixture-pkg": "1.2.3" } + })); + + const packageDirectory = path.join(project.directory, "node_modules", "fixture-pkg"); + await createPackageManifest(packageDirectory, "fixture-pkg", "1.2.3", { + type: "module", + main: "./index.ts" + }); + await writeSourceFile(path.join(packageDirectory, "index.ts"), "export const fixture = 11;\n"); + + await createLockFile(project.directory, "fixture-project", "1.0.0", [ + { path: "node_modules/fixture-pkg", version: "1.2.3" } + ], { + dependencies: { + "fixture-pkg": "1.2.3" + } + }); + + await writeBuildFile(project, "systems/ä.ts", "export const a = 11;\n"); + await writeBuildFile(project, "systems/z.ts", "export const z = 26;\n"); + await writeBuildFile(project, "provider.ts", [ + "import type { DynamicsSession } from \"simfile/dynamics\";", + "import { fixture } from \"fixture-pkg\";", + "import { a } from \"./systems/ä.ts\";", + "import { z } from \"./systems/z.ts\";", + "", + "const consumeSession = (_session: DynamicsSession): number => 1;", + "export const value = consumeSession(null as unknown as DynamicsSession) + fixture + a + z;" + ].join("\n") + "\n"); + + return project; +}; + +const scrubLockEvidence = (receipt: DynamicsBuildReceipt): unknown => ({ + ...receipt.payload, + build_tools: receipt.payload.build_tools.map((entry) => ({ + ...entry, + lock_sha256: "LOCK_SHA256" + })), + deduped_locks: receipt.payload.deduped_locks.map((entry) => ({ + ...entry, + lock_sha256: "LOCK_SHA256" + })).sort((left, right) => Buffer.compare(Buffer.from(JSON.stringify(left)), Buffer.from(JSON.stringify(right)))), + portable_claims: receipt.payload.portable_claims.map((entry) => ({ + ...entry, + lock_sha256: "LOCK_SHA256", + tool_identities: entry.tool_identities.map((tool) => ({ + ...tool, + lock_sha256: "LOCK_SHA256" + })) + })) +}); + +const assertReceiptCanonical = (receipt: DynamicsBuildReceipt): void => { + const receiptText = Buffer.from(receipt.receiptBytes).toString("utf8"); + assert.equal(receipt.receiptBytes[receipt.receiptBytes.length - 1], 10); + assert.equal(receiptText.endsWith("\n"), true); + assert.equal(receipt.receiptSha256, sha256(Uint8Array.from(receipt.receiptBytes))); +}; + +test("actual receipt cross-root and cross-locale stability", async () => { + const tmpRoot = await realpath(os.tmpdir()); + const roots: string[] = []; + try { + const rootOne = await mkdtemp(path.join(tmpRoot, "simfile-b13-locale-one-")); + roots.push(rootOne); + const rootTwo = await mkdtemp(path.join(tmpRoot, "simfile-b13-locale-two-")); + roots.push(rootTwo); + const fixtureOne = await createLocaleReceiptFixture(rootOne); + const fixtureTwo = await createLocaleReceiptFixture(rootTwo); + const children = await Promise.all([ + runLocaleReceiptChild(fixtureOne.directory, "en-US"), + runLocaleReceiptChild(fixtureOne.directory, "sv-SE"), + runLocaleReceiptChild(fixtureTwo.directory, "en-US"), + runLocaleReceiptChild(fixtureTwo.directory, "sv-SE") + ]); + + assert.equal(new Set(children.map((entry) => entry.childPid)).size, 4); + + const expectedPairs: ReadonlyArray<[LocaleProcessLocale, readonly string[]]> = [ + ["en-US", LOCALE_CONTROL_ORDER["en-US"]], + ["sv-SE", LOCALE_CONTROL_ORDER["sv-SE"]], + ["en-US", LOCALE_CONTROL_ORDER["en-US"]], + ["sv-SE", LOCALE_CONTROL_ORDER["sv-SE"]] + ]; + for (let index = 0; index < children.length; index += 1) { + const child = children[index]!; + const [expectedLocale, expectedOrder] = expectedPairs[index]!; + assert.equal(child.requestedLocale, expectedLocale); + assert.deepEqual(child.controlOrder, expectedOrder); + assert.equal(child.localeEnvironment.lang, LOCALE_ENV[expectedLocale]); + assert.equal(child.localeEnvironment.lcAll, LOCALE_ENV[expectedLocale]); + assert.equal(child.resolvedLocale.toLowerCase().startsWith(expectedLocale.toLowerCase()), true); + } + + assert.notDeepEqual(children[0]!.controlOrder, children[1]!.controlOrder); + assert.notDeepEqual(children[2]!.controlOrder, children[3]!.controlOrder); + + const rootOneReal = await realpath(fixtureOne.directory); + const rootTwoReal = await realpath(fixtureTwo.directory); + const forbiddenRoots = [ + fixtureOne.directory, + fixtureTwo.directory, + rootOne, + rootTwo, + rootOneReal, + rootTwoReal, + tmpRoot, + await realpath(fixtureOne.directory), + await realpath(fixtureTwo.directory) + ]; + + const baseline = children[0]!.receipt; + assertReceiptCanonical(baseline); + + const baselinePayloadText = JSON.stringify(baseline.payload); + const baselineReceiptText = Buffer.from(baseline.receiptBytes).toString("utf8"); + assertNoForbiddenText(baselinePayloadText, forbiddenRoots); + assertNoForbiddenText(baselineReceiptText, forbiddenRoots); + + for (const child of children) { + const payloadText = JSON.stringify(child.receipt.payload); + const receiptText = Buffer.from(child.receipt.receiptBytes).toString("utf8"); + assert.deepEqual(baseline.payload, child.receipt.payload); + assert.deepEqual(baseline.receiptBytes, child.receipt.receiptBytes); + assert.equal(baseline.receiptSha256, child.receipt.receiptSha256); + assertReceiptCanonical(child.receipt); + assertNoForbiddenText(payloadText, forbiddenRoots); + assertNoForbiddenText(receiptText, forbiddenRoots); + } + } finally { + await removeBuildTestPaths(...roots); + } +}); + +test("fresh authority/post-prepare failure matrix", async () => { + const fixtureA = await createPackageAndTypeFixture(); + try { + const manifestPath = path.join(fixtureA.projectRoot, "node_modules", "fixture-pkg", "package.json"); + const manifest = JSON.parse(await readFile(manifestPath, "utf8")); + await writeFile(manifestPath, JSON.stringify({ + ...manifest, + version: "1.2.4" + })); + await assert.rejects( + () => createDynamicsBuildReceipt(fixtureA.absoluteSimfilePath, fixtureA.prepared), + /missing package lock evidence|claim mismatch|source hash mismatch|mismatch|manifest/i + ); + } finally { + await removeBuildTestPaths(fixtureA.projectRoot); + } + + const fixtureB = await createPackageAndTypeFixture(); + try { + await rm(path.join(fixtureB.projectRoot, "package-lock.json"), { force: true }); + await assert.rejects( + () => createDynamicsBuildReceipt(fixtureB.absoluteSimfilePath, fixtureB.prepared), + /partial project authority/i + ); + } finally { + await removeBuildTestPaths(fixtureB.projectRoot); + } +}); + +test("receipt-level ambiguity propagation", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + const wrapper = path.join(fixture.projectRoot, "node_modules", "wrapper", "node_modules", "fixture-pkg"); + await mkdir(wrapper, { recursive: true }); + await createPackageManifest(wrapper, "fixture-pkg", "1.2.3", { + type: "module", + main: "./index.ts" + }); + await writeSourceFile(path.join(wrapper, "index.ts"), "export const fixture = 11;\n"); + + const rawLock = await readFile(path.join(fixture.projectRoot, "package-lock.json"), "utf8"); + const lock = JSON.parse(rawLock) as { packages?: Record> }; + if (!lock.packages || typeof lock.packages !== "object") { + throw new Error("invalid lock package table"); + } + + lock.packages["node_modules/wrapper/node_modules/fixture-pkg"] = { + name: "fixture-pkg", + version: "1.2.3" + }; + + await writeFile(path.join(fixture.projectRoot, "package-lock.json"), JSON.stringify(lock)); + await assert.rejects( + () => createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared), + /ambiguous package lock evidence/i + ); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); + +test("portable omission of hostile lock metadata with deterministic evidence deltas", async () => { + const fixture = await createPackageAndTypeFixture(); + try { + const baseline = await createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared); + + const hostileUrl = "https://ci-user:secret@registry.example.test/fixture-pkg-1.2.3.tgz?token=fixture&secret=1#sha256"; + const rawLock = await readFile(path.join(fixture.projectRoot, "package-lock.json"), "utf8"); + const lock = JSON.parse(rawLock) as { packages: Record> }; + if (!lock.packages[""] || !lock.packages["node_modules/fixture-pkg"]) { + throw new Error("missing lock entries"); + } + + lock.packages[""] = { + ...lock.packages[""], + resolved: "https://registry.example.test?token=project#secret", + registry: "registry.example.test", + integrity: "sha512-hostile-root", + control: "control\u0007character" + }; + lock.packages["node_modules/fixture-pkg"] = { + ...lock.packages["node_modules/fixture-pkg"], + resolved: hostileUrl, + registry: "registry.example.test", + integrity: "sha512-hostile-pkg", + token: "token-secret", + secret: "secret-token" + }; + await writeFile(path.join(fixture.projectRoot, "package-lock.json"), JSON.stringify(lock)); + + const hostile = await createDynamicsBuildReceipt(fixture.absoluteSimfilePath, fixture.prepared); + + assertReceiptCanonical(baseline); + assertReceiptCanonical(hostile); + + const hostilePayloadText = JSON.stringify(hostile.payload); + const hostileReceiptText = Buffer.from(hostile.receiptBytes).toString("utf8"); + const baselinePayloadText = JSON.stringify(baseline.payload); + const baselineReceiptText = Buffer.from(baseline.receiptBytes).toString("utf8"); + const baselineRoots = [ + fixture.projectRoot, + fixture.authority.absoluteProjectRoot, + fixture.authority.absoluteToolchainRoot, + fixture.authority.toolchainAuthority.absoluteLockPath, + fixture.authority.projectAuthority.absoluteLockPath ?? "", + fixture.authority.absoluteToolchainRoot + ]; + assertNoForbiddenText(hostilePayloadText, baselineRoots); + assertNoForbiddenText(baselineReceiptText, baselineRoots); + assertNoForbiddenText(hostileReceiptText, baselineRoots); + assert.equal(hostilePayloadText.includes("https://"), false); + assert.equal(hostilePayloadText.includes("registry.example.test"), false); + assert.equal(hostilePayloadText.includes("token"), false); + assert.equal(hostilePayloadText.includes("secret"), false); + assert.equal(hostilePayloadText.includes("?"), false); + assert.equal(hostilePayloadText.includes("#"), false); + assert.equal(hostileReceiptText.includes("?"), false); + assert.equal(hostileReceiptText.includes("#"), false); + assert.equal(hostileReceiptText.includes("https://"), false); + assert.equal(hostileReceiptText.includes("token"), false); + assert.equal(hostileReceiptText.includes("secret"), false); + assertNoForbiddenText(baselinePayloadText, baselineRoots); + + assert.equal(baseline.receiptSha256 !== hostile.receiptSha256, true); + assert.deepEqual(scrubLockEvidence(baseline), scrubLockEvidence(hostile)); + assert.notDeepEqual(baseline.payload.deduped_locks, hostile.payload.deduped_locks); + assert.notDeepEqual(baseline.payload.portable_claims, hostile.payload.portable_claims); + assert.deepEqual(baseline.payload.source_graph, hostile.payload.source_graph); + assert.deepEqual(baseline.payload.build_config_sha256, hostile.payload.build_config_sha256); + assert.deepEqual(baseline.payload.artifact_sha256, hostile.payload.artifact_sha256); + assert.deepEqual(baseline.payload.artifact_path, hostile.payload.artifact_path); + } finally { + await removeBuildTestPaths(fixture.projectRoot); + } +}); diff --git a/src/dynamics/buildReceiptLock.test-helper.ts b/src/dynamics/buildReceiptLock.test-helper.ts new file mode 100644 index 0000000..c01424c --- /dev/null +++ b/src/dynamics/buildReceiptLock.test-helper.ts @@ -0,0 +1,120 @@ +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + realpath, + readFile, + rm, + symlink, + writeFile +} from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +export const sha256 = (value: string | Uint8Array): string => createHash("sha256").update(value).digest("hex"); + +export const withTemp = async (callback: (root: string) => Promise): Promise => { + const tmpRoot = await realpath(os.tmpdir()); + const root = await mkdtemp(path.join(tmpRoot, "simfile-b62-")); + try { + return await callback(root); + } finally { + await rm(root, { force: true, recursive: true }); + } +}; + +export const writeJson = async (filePath: string, value: unknown): Promise => { + await writeFile(filePath, JSON.stringify(value), "utf8"); +}; + +export const createPackageManifest = async ( + packageRoot: string, + name: string, + version: string, + extra: Record = {} +): Promise => { + await mkdir(packageRoot, { recursive: true }); + const manifest = { name, version, ...extra }; + const raw = JSON.stringify(manifest); + await writeFile(path.join(packageRoot, "package.json"), raw, "utf8"); + return sha256(raw); +}; + +export const writeSourceFile = async (filePath: string, value: string): Promise => { + await mkdir(path.dirname(filePath), { recursive: true }); + await writeFile(filePath, value, "utf8"); + return sha256(value); +}; + +export const createLockFile = async ( + root: string, + rootName: string, + rootVersion: string, + entries: ReadonlyArray<{ + readonly path: string; + readonly version?: string; + readonly name?: string; + readonly resolved?: string; + readonly link?: boolean; + }>, + rootDependencies: { + dependencies?: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + } = {} +): Promise => { + const packageEntries: Record; + devDependencies?: Record; + optionalDependencies?: Record; + peerDependencies?: Record; + }> = { + "": { + name: rootName, + version: rootVersion, + dependencies: rootDependencies.dependencies, + devDependencies: rootDependencies.devDependencies, + optionalDependencies: rootDependencies.optionalDependencies, + peerDependencies: rootDependencies.peerDependencies + } + }; + for (const entry of entries) { + packageEntries[entry.path] = { + ...(entry.name === undefined ? {} : { name: entry.name }), + ...(entry.resolved === undefined ? {} : { resolved: entry.resolved }), + ...(entry.link === undefined ? {} : { link: entry.link }), + ...(entry.version === undefined ? {} : { version: entry.version }) + }; + } + + await writeJson(path.join(root, "package-lock.json"), { + name: rootName, + version: rootVersion, + lockfileVersion: 3, + packages: packageEntries + }); +}; + +export const createSimfile = async (projectRoot: string): Promise => { + await mkdir(projectRoot, { recursive: true }); + const simfilePath = path.join(projectRoot, "Simfile"); + await writeFile(simfilePath, "{}", "utf8"); + return simfilePath; +}; + +export const readFileDigest = async (filePath: string): Promise => sha256(await readFile(filePath)); + +export const createSymlinkDirectory = async (source: string, destination: string): Promise => { + await mkdir(path.dirname(destination), { recursive: true }); + await symlink(source, destination); +}; + +export const createSymlinkFile = async (source: string, destination: string): Promise => { + await mkdir(path.dirname(destination), { recursive: true }); + await symlink(source, destination); +}; diff --git a/src/dynamics/buildReceiptLock.test.ts b/src/dynamics/buildReceiptLock.test.ts new file mode 100644 index 0000000..79a4c1e --- /dev/null +++ b/src/dynamics/buildReceiptLock.test.ts @@ -0,0 +1,323 @@ +import "./buildReceiptLockAuthority.test-helper.js"; + +import assert from "node:assert/strict"; +import { mkdir } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; +import { + buildReceiptLock, + compareDynamicsReceiptLockPortableRecords, + type DynamicsReceiptLockPortableRecord +} from "./buildReceiptLock.js"; +import { + createLockFile, + createPackageManifest, + createSimfile, + createSymlinkDirectory, + createSymlinkFile, + withTemp, + writeJson, + writeSourceFile +} from "./buildReceiptLock.test-helper.js"; + +const TOOLCHAIN_ESBUILD_VERSION = "0.28.1"; +const TOOLCHAIN_TYPESCRIPT_VERSION = "5.9.3"; + +const assertDeepFrozen = (value: unknown, label: string): void => { + if (value === null || typeof value !== "object") return; + assert.equal(Object.isFrozen(value), true, `${label} should be frozen`); + if (Array.isArray(value)) { + for (const [index, child] of value.entries()) { + assertDeepFrozen(child, `${label}[${index}]`); + } + return; + } + for (const [name, child] of Object.entries(value as Record)) { + assertDeepFrozen(child, `${label}.${name}`); + } +}; + +const assertNoPortals = (claim: { + readonly source_digests: readonly { readonly path: string }[]; + readonly tool_identities: readonly { readonly lock_entry_path: string }[]; +}): void => { + for (const source of claim.source_digests) { + assert.equal(path.isAbsolute(source.path), false, `absolute path leaked: ${source.path}`); + assert.equal(path.posix.isAbsolute(source.path), false, `claim path absolute: ${source.path}`); + assert.equal(/\x00/.test(source.path), false, `control char leaked: ${source.path}`); + assert.equal(/\?/.test(source.path), false, `query leaked: ${source.path}`); + assert.equal(/#/.test(source.path), false, `fragment leaked: ${source.path}`); + assert.equal(/https?:\/\//.test(source.path), false, `url leaked: ${source.path}`); + } + for (const tool of claim.tool_identities) { + assert.equal(path.isAbsolute(tool.lock_entry_path), false, `absolute tool path leaked: ${tool.lock_entry_path}`); + assert.equal(/[\\]/.test(tool.lock_entry_path), false, `platform path leaked: ${tool.lock_entry_path}`); + } +}; + +test("buildReceiptLock API is issuer-only", () => { + assert.equal(buildReceiptLock.length, 4); +}); + +test("portable claim ordering includes authority identity", () => { + const base: DynamicsReceiptLockPortableRecord = { + manager: "npm", + lockfile_version: 3, + root_package_name: "a", + root_package_version: "1.0.0", + root_package_sha256: "a".repeat(64), + lock_sha256: "b".repeat(64), + lock_entry_path: "node_modules/pkg", + package_name: "pkg", + package_version: "1.0.0", + package_manifest_sha256: "c".repeat(64), + source_digests: [{ path: "./index.js", sha256: "d".repeat(64) }], + tool_identities: [] + }; + const differentAuthority = { ...base, root_package_name: "b" }; + assert.ok(compareDynamicsReceiptLockPortableRecords(base, differentAuthority) < 0); + assert.ok(compareDynamicsReceiptLockPortableRecords(differentAuthority, base) > 0); + assert.deepEqual( + [differentAuthority, base].sort(compareDynamicsReceiptLockPortableRecords).map((claim) => claim.root_package_name), + ["a", "b"] + ); +}); + +test("simfile path must be absolute regular non-symlink", async () => { + await withTemp(async (root) => { + const project = path.join(root, "project"); + const simfile = await createSimfile(project); + + await assert.rejects( + () => buildReceiptLock("project/Simfile", [], TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /must be absolute/ + ); + + const real = path.join(root, "real"); + const realSimfile = path.join(real, "Simfile"); + await createSimfile(real); + await createSymlinkDirectory(real, path.join(root, "linked")); + await assert.rejects( + () => buildReceiptLock(path.join(root, "linked", "Simfile"), [], TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /forbidden symlink/ + ); + + const fileLink = path.join(root, "file-link"); + await createSymlinkFile(realSimfile, fileLink); + await assert.rejects( + () => buildReceiptLock(fileLink, [], TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /forbidden symlink/ + ); + + await mkdir(path.join(project, "is-dir")); + await assert.rejects( + () => buildReceiptLock(path.join(project, "is-dir"), [], TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /non-file path/ + ); + + const result = await buildReceiptLock(simfile, [], TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION); + assert.equal(result.projectAuthority.absoluteLockRoot, null); + + await withTemp(async (projectRoot) => { + const partial = path.join(projectRoot, "project"); + const partialSimfile = await createSimfile(partial); + await writeJson(path.join(partial, "package.json"), { name: "project", version: "1.0.0" }); + await assert.rejects( + () => buildReceiptLock(partialSimfile, [], TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /partial project authority/ + ); + }); + + await withTemp(async (lockRoot) => { + const partial = path.join(lockRoot, "project"); + const partialSimfile = await createSimfile(partial); + await writeJson(path.join(partial, "package-lock.json"), { + lockfileVersion: 3, + packages: {} + }); + await assert.rejects( + () => buildReceiptLock(partialSimfile, [], TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /partial project authority/ + ); + }); + }); +}); + +test("portable claims are deterministic and POSIX", async () => { + await withTemp(async (root) => { + const project = path.join(root, "project"); + const simfile = await createSimfile(project); + + const fixtureRoot = path.join(project, "node_modules", "fixture-pkg"); + const manifest = await createPackageManifest(fixtureRoot, "fixture-pkg", "1.2.3"); + const sourceOne = await writeSourceFile(path.join(fixtureRoot, "src", "alpha.ts"), "export const value = 1;\n"); + const sourceTwo = await writeSourceFile(path.join(fixtureRoot, "src", "omega.ts"), "export const value = 2;\n"); + + await writeJson(path.join(project, "package.json"), { + name: "project", + version: "1.0.0", + dependencies: { + fixture: "1.2.3", + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + + await createLockFile(project, "project", "1.0.0", [ + { path: "node_modules/fixture-pkg", version: "1.2.3", name: "fixture-pkg" } + ], { + dependencies: { + fixture: "1.2.3", + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + + const primary = { + kind: "package" as const, + manifest_sha256: manifest, + modes: ["runtime"] as const, + package_name: "fixture-pkg", + package_version: "1.2.3", + package_path: "./src/omega.ts", + sha256: sourceTwo + }; + const secondary = { + kind: "package" as const, + manifest_sha256: manifest, + modes: ["runtime"] as const, + package_name: "fixture-pkg", + package_version: "1.2.3", + package_path: "./src/alpha.ts", + sha256: sourceOne + }; + + const result = await buildReceiptLock(simfile, [ + secondary, + primary + ], TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION); + + assert.deepEqual(result.portableClaims.map((claim) => claim.source_digests[0].path), [ + "./src/alpha.ts", + "./src/omega.ts" + ]); + + const fixture = result.portableClaims.find((claim) => claim.package_name === "fixture-pkg"); + assert.ok(fixture); + assert.equal(fixture.lock_entry_path, "node_modules/fixture-pkg"); + assert.equal(fixture.source_digests[0].path, "./src/alpha.ts"); + assert.equal(fixture.source_digests[0].sha256, sourceOne); + + assert.equal(result.toolchainAuthority.tool_identities.length, 2); + for (const tool of result.toolchainAuthority.tool_identities) { + assert.equal(/[\\]/.test(tool.lock_entry_path), false, `tool path not posix: ${tool.lock_entry_path}`); + } + assert.equal(result.toolchainAuthority.tool_identities[0].lock_entry_path, "node_modules/esbuild"); + assert.deepEqual(result.selfLinkEntries, []); + + for (const claim of result.portableClaims) { + assertNoPortals(claim); + assertDeepFrozen(claim, "claim"); + } + assertDeepFrozen(result, "result"); + }); +}); + +test("package descriptor ambiguity is rejected", async () => { + await withTemp(async (root) => { + const packageName = "fixture-pkg-local"; + const project = path.join(root, "project"); + const simfile = await createSimfile(project); + + await writeJson(path.join(project, "package.json"), { + name: "project", + version: "1.0.0", + dependencies: { + [packageName]: "1.2.3", + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + const pkgRoot = path.join(project, "node_modules", packageName); + const manifest = await createPackageManifest(pkgRoot, packageName, "1.2.3"); + const source = await writeSourceFile(path.join(pkgRoot, "src", "index.ts"), "export const value = 1;\n"); + + await createLockFile(project, "project", "1.0.0", [{ path: `node_modules/${packageName}`, version: "9.9.9" }], { + dependencies: { [packageName]: "1.2.3", esbuild: TOOLCHAIN_ESBUILD_VERSION, typescript: TOOLCHAIN_TYPESCRIPT_VERSION } + }); + await assert.rejects( + () => buildReceiptLock(simfile, [{ + kind: "package" as const, + manifest_sha256: manifest, + modes: ["runtime"] as const, + package_name: packageName, + package_path: "./src/index.ts", + package_version: "1.2.3", + sha256: source + }], TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /missing package lock evidence/ + ); + + await createLockFile(project, "project", "1.0.0", [ + { path: `node_modules/${packageName}`, version: "1.2.3" }, + { path: `node_modules/wrapper/node_modules/${packageName}`, version: "1.2.3" } + ], { + dependencies: { + [packageName]: "1.2.3", + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + await createPackageManifest(path.join(project, "node_modules", "wrapper", "node_modules", packageName), packageName, "1.2.3"); + await writeSourceFile(path.join(project, "node_modules", "wrapper", "node_modules", packageName, "src", "index.ts"), "export const value = 1;\n"); + await assert.rejects( + () => buildReceiptLock(simfile, [{ + kind: "package" as const, + manifest_sha256: manifest, + modes: ["runtime"] as const, + package_name: packageName, + package_path: "./src/index.ts", + package_version: "1.2.3", + sha256: source + }], TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /ambiguous package lock evidence/ + ); + }); +}); + +test("symlinked package directory component is rejected", async () => { + await withTemp(async (root) => { + const project = path.join(root, "project"); + const simfile = await createSimfile(project); + await writeJson(path.join(project, "package.json"), { + name: "project", + version: "1.0.0", + dependencies: { + "fixture-pkg": "1.2.3", + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + + const real = path.join(project, "real", "node_modules", "fixture-pkg"); + const manifest = await createPackageManifest(real, "fixture-pkg", "1.2.3"); + const source = await writeSourceFile(path.join(real, "src", "index.ts"), "export const value = 1;\n"); + await createSymlinkDirectory(real, path.join(project, "node_modules", "fixture-pkg")); + await createLockFile(project, "project", "1.0.0", [{ path: "node_modules/fixture-pkg", version: "1.2.3" }], { + dependencies: { "fixture-pkg": "1.2.3", esbuild: TOOLCHAIN_ESBUILD_VERSION, typescript: TOOLCHAIN_TYPESCRIPT_VERSION } + }); + + await assert.rejects( + () => buildReceiptLock(simfile, [{ + kind: "package" as const, + manifest_sha256: manifest, + modes: ["runtime"] as const, + package_name: "fixture-pkg", + package_path: "./src/index.ts", + package_version: "1.2.3", + sha256: source + }], TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /forbidden symlink/ + ); + }); +}); diff --git a/src/dynamics/buildReceiptLock.ts b/src/dynamics/buildReceiptLock.ts new file mode 100644 index 0000000..7f38227 --- /dev/null +++ b/src/dynamics/buildReceiptLock.ts @@ -0,0 +1,271 @@ +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { compareUtf16, deepFreeze, type DynamicsBuildInputDescriptor } from "./buildIdentity.js"; +import { + buildTypeOnlyClaim, + resolvePackageDescriptor, + resolveProjectAuthority, + resolveSimfileProjectRoot, + resolveToolchainAuthorityFromAnchor, + type DynamicsReceiptLockProjectAuthority, + type DynamicsReceiptLockToolchainAuthority, + type DynamicsReceiptLockToolIdentity +} from "./buildReceiptLockFiles.js"; +import { + assertCanonicalSelfLinkEntries, + type DynamicsReceiptSelfLinkEntry +} from "./buildReceiptSelfLinks.js"; + +export type { DynamicsReceiptLockToolIdentity }; +export type { DynamicsReceiptSelfLinkEntry }; + +export interface DynamicsReceiptLockPortableRecord { + readonly manager: "npm"; + readonly lockfile_version: 3; + readonly root_package_name: string; + readonly root_package_version: string; + readonly root_package_sha256: string; + readonly lock_sha256: string; + readonly lock_entry_path: string; + readonly package_name: string; + readonly package_version: string; + readonly package_manifest_sha256: string; + readonly source_digests: readonly Readonly<{ readonly path: string; readonly sha256: string }> []; + readonly tool_identities: readonly DynamicsReceiptLockToolIdentity[]; +} + +export interface DynamicsReceiptLockDeduplicatedLock { + readonly manager: "npm"; + readonly lockfile_version: 3; + readonly root_package_name: string; + readonly root_package_version: string; + readonly root_package_sha256: string; + readonly lock_sha256: string; +} + +export interface DynamicsReceiptLockAuthority { + readonly absoluteProjectRoot: string; + readonly absoluteToolchainRoot: string; + readonly toolchainAuthority: Readonly<{ + readonly absoluteLockRoot: string; + readonly absoluteLockPath: string; + readonly lockfile_version: 3; + readonly root_package_name: string; + readonly root_package_version: string; + readonly root_package_sha256: string; + readonly lock_sha256: string; + readonly tool_identities: readonly DynamicsReceiptLockToolIdentity[]; + }>; + readonly projectAuthority: Readonly<{ + readonly absoluteLockRoot: string | null; + readonly absoluteLockPath: string | null; + readonly lockfile_version: 3 | null; + readonly root_package_name: string | null; + readonly root_package_version: string | null; + readonly root_package_sha256: string | null; + }>; + readonly dedupedLocks: readonly DynamicsReceiptLockDeduplicatedLock[]; + readonly dedupedLockRealpaths: readonly string[]; + readonly portableClaims: readonly DynamicsReceiptLockPortableRecord[]; + readonly selfLinkEntries: readonly DynamicsReceiptSelfLinkEntry[]; +} + +const fail = (message: string): never => { throw new Error(message); }; + +type AnyLockAuthority = DynamicsReceiptLockToolchainAuthority | DynamicsReceiptLockProjectAuthority; + +export const coalesceAuthoritiesByLockRealpath = (authorities: readonly AnyLockAuthority[]): readonly AnyLockAuthority[] => { + const map = new Map(); + for (const authority of authorities) { + const existing = map.get(authority.absoluteLockRealPath); + if (existing === undefined || (existing.kind === "project" && authority.kind === "toolchain")) { + map.set(authority.absoluteLockRealPath, authority); + } + } + return Array.from(map.values()); +}; + +const dedupeLocks = (authorities: readonly AnyLockAuthority[]): readonly DynamicsReceiptLockDeduplicatedLock[] => { + const index = new Map(); + for (const authority of authorities) { + if (index.has(authority.absoluteLockRealPath)) continue; + index.set(authority.absoluteLockRealPath, { + manager: "npm", + lockfile_version: 3, + root_package_name: authority.root_package_name, + root_package_version: authority.root_package_version, + root_package_sha256: authority.root_package_sha256, + lock_sha256: authority.lock_sha256 + }); + } + return deepFreeze(Array.from(index.values()).sort((left, right) => compareUtf16(left.lock_sha256, right.lock_sha256))); +}; + +const compareSourceDigests = ( + left: ReadonlyArray<{ readonly path: string; readonly sha256: string }>, + right: ReadonlyArray<{ readonly path: string; readonly sha256: string }> +): number => { + if (left.length !== right.length) return compareUtf16(String(left.length), String(right.length)); + for (let index = 0; index < left.length; index += 1) { + const diffPath = compareUtf16(left[index].path, right[index].path); + if (diffPath !== 0) return diffPath; + const diffHash = compareUtf16(left[index].sha256, right[index].sha256); + if (diffHash !== 0) return diffHash; + } + return 0; +}; + +const compareToolIdentities = ( + left: ReadonlyArray<{ readonly name: string; readonly version: string; readonly manifest_sha256: string; readonly lock_entry_path: string; readonly lock_sha256: string }>, + right: ReadonlyArray<{ readonly name: string; readonly version: string; readonly manifest_sha256: string; readonly lock_entry_path: string; readonly lock_sha256: string }> +): number => { + if (left.length !== right.length) return compareUtf16(String(left.length), String(right.length)); + for (let index = 0; index < left.length; index += 1) { + const leftIdentity = left[index]; + const rightIdentity = right[index]; + const diffName = compareUtf16(leftIdentity.name, rightIdentity.name); + if (diffName !== 0) return diffName; + const diffVersion = compareUtf16(leftIdentity.version, rightIdentity.version); + if (diffVersion !== 0) return diffVersion; + const diffManifest = compareUtf16(leftIdentity.manifest_sha256, rightIdentity.manifest_sha256); + if (diffManifest !== 0) return diffManifest; + const diffEntry = compareUtf16(leftIdentity.lock_entry_path, rightIdentity.lock_entry_path); + if (diffEntry !== 0) return diffEntry; + const diffLock = compareUtf16(leftIdentity.lock_sha256, rightIdentity.lock_sha256); + if (diffLock !== 0) return diffLock; + } + return 0; +}; + +export const compareDynamicsReceiptLockPortableRecords = ( + left: DynamicsReceiptLockPortableRecord, + right: DynamicsReceiptLockPortableRecord +): number => { + const diffManager = compareUtf16(left.manager, right.manager); + if (diffManager !== 0) return diffManager; + + const diffLockVersion = compareUtf16(String(left.lockfile_version), String(right.lockfile_version)); + if (diffLockVersion !== 0) return diffLockVersion; + + const diffRootName = compareUtf16(left.root_package_name, right.root_package_name); + if (diffRootName !== 0) return diffRootName; + + const diffRootVersion = compareUtf16(left.root_package_version, right.root_package_version); + if (diffRootVersion !== 0) return diffRootVersion; + + const diffRootManifest = compareUtf16(left.root_package_sha256, right.root_package_sha256); + if (diffRootManifest !== 0) return diffRootManifest; + + const diffLock = compareUtf16(left.lock_sha256, right.lock_sha256); + if (diffLock !== 0) return diffLock; + + const diffEntry = compareUtf16(left.lock_entry_path, right.lock_entry_path); + if (diffEntry !== 0) return diffEntry; + + const diffPackageName = compareUtf16(left.package_name, right.package_name); + if (diffPackageName !== 0) return diffPackageName; + + const diffPackageVersion = compareUtf16(left.package_version, right.package_version); + if (diffPackageVersion !== 0) return diffPackageVersion; + + const diffPackageManifest = compareUtf16(left.package_manifest_sha256, right.package_manifest_sha256); + if (diffPackageManifest !== 0) return diffPackageManifest; + + const diffSource = compareSourceDigests(left.source_digests, right.source_digests); + if (diffSource !== 0) return diffSource; + + return compareToolIdentities(left.tool_identities, right.tool_identities); +}; + +export const buildReceiptLock = async ( + absoluteSimfilePath: string, + input: readonly DynamicsBuildInputDescriptor[], + esbuildVersion: string, + typescriptVersion: string +): Promise => { + if (!path.isAbsolute(absoluteSimfilePath)) fail(`simfile path must be absolute: ${absoluteSimfilePath}`); + + const absoluteProjectRoot = await resolveSimfileProjectRoot(absoluteSimfilePath); + const toolchainAuthority = await resolveToolchainAuthorityFromAnchor( + fileURLToPath(import.meta.url), + esbuildVersion, + typescriptVersion + ); + const projectAuthority = await resolveProjectAuthority(absoluteProjectRoot, toolchainAuthority); + + const authorities: Array = [toolchainAuthority]; + if (projectAuthority !== null) authorities.push(projectAuthority); + const dedupedAuthorities = coalesceAuthoritiesByLockRealpath(authorities); + + const dedupedLocks = dedupeLocks(dedupedAuthorities); + const dedupedLockRealpaths = deepFreeze(dedupedAuthorities.map((authority) => authority.absoluteLockRealPath).sort(compareUtf16)); + + const portableClaims: DynamicsReceiptLockPortableRecord[] = []; + for (const descriptor of input) { + if (descriptor.kind === "package") { + const match = await resolvePackageDescriptor(descriptor, dedupedAuthorities); + portableClaims.push(deepFreeze({ + manager: "npm", + lockfile_version: 3, + root_package_name: match.authority.root_package_name, + root_package_version: match.authority.root_package_version, + root_package_sha256: match.authority.root_package_sha256, + lock_sha256: match.authority.lock_sha256, + lock_entry_path: match.lockEntryPath, + package_name: descriptor.package_name, + package_version: descriptor.package_version, + package_manifest_sha256: descriptor.manifest_sha256, + source_digests: deepFreeze([{ path: `./${descriptor.package_path.slice(2)}`, sha256: descriptor.sha256 }]), + tool_identities: deepFreeze([]) + })); + continue; + } + + if (descriptor.kind === "type-only") { + const claim = await buildTypeOnlyClaim(descriptor, toolchainAuthority); + portableClaims.push(deepFreeze(claim as DynamicsReceiptLockPortableRecord)); + continue; + } + + fail("unsupported descriptor kind: project"); + } + + const sortedClaims = [...portableClaims].sort(compareDynamicsReceiptLockPortableRecords); + const selfLinkEntries = assertCanonicalSelfLinkEntries(projectAuthority?.selfLinkEntries ?? []); + + return deepFreeze({ + absoluteProjectRoot, + absoluteToolchainRoot: toolchainAuthority.absoluteRoot, + toolchainAuthority: deepFreeze({ + absoluteLockRoot: toolchainAuthority.absoluteLockRoot, + absoluteLockPath: toolchainAuthority.absoluteLockPath, + lockfile_version: 3, + root_package_name: toolchainAuthority.root_package_name, + root_package_version: toolchainAuthority.root_package_version, + root_package_sha256: toolchainAuthority.root_package_sha256, + lock_sha256: toolchainAuthority.lock_sha256, + tool_identities: toolchainAuthority.toolIdentities + }), + projectAuthority: projectAuthority === null + ? deepFreeze({ + absoluteLockRoot: null, + absoluteLockPath: null, + lockfile_version: null, + root_package_name: null, + root_package_version: null, + root_package_sha256: null + }) + : deepFreeze({ + absoluteLockRoot: projectAuthority.absoluteLockRoot, + absoluteLockPath: projectAuthority.absoluteLockPath, + lockfile_version: 3, + root_package_name: projectAuthority.root_package_name, + root_package_version: projectAuthority.root_package_version, + root_package_sha256: projectAuthority.root_package_sha256 + }), + dedupedLocks, + dedupedLockRealpaths, + portableClaims: deepFreeze(sortedClaims), + selfLinkEntries + }); +}; diff --git a/src/dynamics/buildReceiptLockAuthority.test-helper.ts b/src/dynamics/buildReceiptLockAuthority.test-helper.ts new file mode 100644 index 0000000..1bcc888 --- /dev/null +++ b/src/dynamics/buildReceiptLockAuthority.test-helper.ts @@ -0,0 +1,321 @@ +import assert from "node:assert/strict"; +import { realpath, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { coalesceAuthoritiesByLockRealpath } from "./buildReceiptLock.js"; +import { + resolvePackageDescriptor, + resolveProjectAuthority, + buildTypeOnlyClaim +} from "./buildReceiptLockFiles.js"; +import { resolveToolchainAuthorityFromAnchor } from "./buildReceiptLockAuthority.js"; +import { + createLockFile, + createPackageManifest, + createSimfile, + withTemp, + writeJson, + writeSourceFile +} from "./buildReceiptLock.test-helper.js"; +import { assertPortablePath } from "./buildReceiptLockPath.js"; + +const TOOLCHAIN_ESBUILD_VERSION = "0.28.1"; +const TOOLCHAIN_TYPESCRIPT_VERSION = "5.9.3"; +const TOOLCHAIN_NAME = "simfile"; +const TOOLCHAIN_VERSION = "0.0.1"; + +const createSimpleToolPackage = async (root: string, name: string, version: string): Promise => { + await createPackageManifest(root, name, version, { main: "index.js" }); + await writeSourceFile(path.join(root, "index.js"), `exports.name = ${JSON.stringify(name)};\n`); +}; + +const resolveProjectAuthorityForTest = async (project: string) => resolveProjectAuthority( + project, + await resolveToolchainAuthorityFromAnchor( + fileURLToPath(import.meta.url), + TOOLCHAIN_ESBUILD_VERSION, + TOOLCHAIN_TYPESCRIPT_VERSION + ) +); + +test("project lock parse rejects malformed root lock data", async () => { + await withTemp(async (root) => { + const project = path.join(root, "project"); + await createSimfile(project); + await writeJson(path.join(project, "package.json"), { name: "project", version: "1.0.0" }); + await writeJson(path.join(project, "package-lock.json"), { + name: "project", + version: "1.0.0", + lockfileVersion: 3, + packages: {} + }); + + await assert.rejects( + () => resolveProjectAuthorityForTest(project), + /invalid package-lock packages entry/ + ); + }); +}); + +test("project lock dependency drift is surfaced", async () => { + await withTemp(async (root) => { + const project = path.join(root, "project"); + await createSimfile(project); + await writeJson(path.join(project, "package.json"), { + name: "project", + version: "1.0.0", + dependencies: { fixture: "1.2.3" } + }); + await writeJson(path.join(project, "package-lock.json"), { + name: "project", + version: "1.0.0", + lockfileVersion: 3, + packages: { + "": { + name: "project", + version: "1.0.0", + dependencies: {} + } + } + }); + + await assert.rejects( + () => resolveProjectAuthorityForTest(project), + /lock root dependency drift: dependencies/ + ); + }); +}); + +test("toolchain authority rejects stale nearer lock instead of falling through", async () => { + await withTemp(async (root) => { + const toolchainRoot = path.join(root, "toolchain"); + const anchor = path.join(toolchainRoot, "probe.js"); + await createSimfile(toolchainRoot); + await writeFile(anchor, "", "utf8"); + + await createSimpleToolPackage(path.join(toolchainRoot, "node_modules", "esbuild"), "esbuild", TOOLCHAIN_ESBUILD_VERSION); + await createSimpleToolPackage(path.join(toolchainRoot, "node_modules", "typescript"), "typescript", TOOLCHAIN_TYPESCRIPT_VERSION); + + await writeJson(path.join(toolchainRoot, "node_modules", "package.json"), { + name: TOOLCHAIN_NAME, + version: TOOLCHAIN_VERSION, + dependencies: { + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + await createLockFile(path.join(toolchainRoot, "node_modules"), TOOLCHAIN_NAME, TOOLCHAIN_VERSION, [ + { path: "node_modules/esbuild", version: TOOLCHAIN_ESBUILD_VERSION }, + { path: "node_modules/typescript", version: TOOLCHAIN_TYPESCRIPT_VERSION } + ], { + dependencies: { + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + + await writeJson(path.join(toolchainRoot, "package.json"), { + name: TOOLCHAIN_NAME, + version: TOOLCHAIN_VERSION, + dependencies: { + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + await createLockFile(toolchainRoot, TOOLCHAIN_NAME, TOOLCHAIN_VERSION, [ + { path: "node_modules/esbuild", version: TOOLCHAIN_ESBUILD_VERSION }, + { path: "node_modules/typescript", version: TOOLCHAIN_TYPESCRIPT_VERSION } + ], { + dependencies: { + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + + await assert.rejects( + () => resolveToolchainAuthorityFromAnchor(anchor, TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /toolchain lock entry mismatch/ + ); + }); +}); + +test("toolchain authority rejects a selected Simfile root with a missing lock", async () => { + await withTemp(async (root) => { + const toolchainRoot = path.join(root, "toolchain"); + const anchor = path.join(toolchainRoot, "probe.js"); + await createSimfile(toolchainRoot); + await writeFile(anchor, "", "utf8"); + await writeJson(path.join(toolchainRoot, "package.json"), { + name: TOOLCHAIN_NAME, + version: TOOLCHAIN_VERSION, + dependencies: { + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + await createSimpleToolPackage(path.join(toolchainRoot, "node_modules", "esbuild"), "esbuild", TOOLCHAIN_ESBUILD_VERSION); + await createSimpleToolPackage(path.join(toolchainRoot, "node_modules", "typescript"), "typescript", TOOLCHAIN_TYPESCRIPT_VERSION); + await createLockFile(toolchainRoot, TOOLCHAIN_NAME, TOOLCHAIN_VERSION, [ + { path: "node_modules/esbuild", version: TOOLCHAIN_ESBUILD_VERSION }, + { path: "node_modules/typescript", version: TOOLCHAIN_TYPESCRIPT_VERSION } + ], { + dependencies: { + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + await writeJson(path.join(toolchainRoot, "node_modules", "package.json"), { + name: TOOLCHAIN_NAME, + version: TOOLCHAIN_VERSION + }); + + await assert.rejects( + () => resolveToolchainAuthorityFromAnchor(anchor, TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /partial toolchain authority/ + ); + }); +}); + +test("toolchain authority rejects an explicit mismatched tool entry name", async () => { + await withTemp(async (root) => { + const toolchainRoot = path.join(root, "toolchain"); + const anchor = path.join(toolchainRoot, "probe.js"); + await createSimfile(toolchainRoot); + await writeFile(anchor, "", "utf8"); + await writeJson(path.join(toolchainRoot, "package.json"), { + name: TOOLCHAIN_NAME, + version: TOOLCHAIN_VERSION, + dependencies: { + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + await createSimpleToolPackage(path.join(toolchainRoot, "node_modules", "esbuild"), "esbuild", TOOLCHAIN_ESBUILD_VERSION); + await createSimpleToolPackage(path.join(toolchainRoot, "node_modules", "typescript"), "typescript", TOOLCHAIN_TYPESCRIPT_VERSION); + await createLockFile(toolchainRoot, TOOLCHAIN_NAME, TOOLCHAIN_VERSION, [ + { path: "node_modules/esbuild", version: TOOLCHAIN_ESBUILD_VERSION, name: "not-esbuild" }, + { path: "node_modules/typescript", version: TOOLCHAIN_TYPESCRIPT_VERSION } + ], { + dependencies: { + esbuild: TOOLCHAIN_ESBUILD_VERSION, + typescript: TOOLCHAIN_TYPESCRIPT_VERSION + } + }); + + await assert.rejects( + () => resolveToolchainAuthorityFromAnchor(anchor, TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION), + /toolchain lock entry name mismatch: esbuild/ + ); + }); +}); + +test("alias lock entry names are respected for package-name resolution", async () => { + await withTemp(async (root) => { + const project = path.join(root, "project"); + await createSimfile(project); + await writeJson(path.join(project, "package.json"), { + name: "project", + version: "1.0.0", + dependencies: { + "@scope/pkg": "1.2.3" + } + }); + + await createPackageManifest(path.join(project, "node_modules", "@scope", "pkg"), "@scope/alias", "1.2.3"); + const aliasManifest = await createPackageManifest(path.join(project, "node_modules", "alias-scoped"), "@scope/pkg", "1.2.3"); + const aliasSource = await writeSourceFile(path.join(project, "node_modules", "alias-scoped", "src", "index.ts"), "export const value = 1;\n"); + await writeSourceFile(path.join(project, "node_modules", "@scope", "pkg", "src", "index.ts"), "export const value = 1;\n"); + await createLockFile(project, "project", "1.0.0", [ + { path: "node_modules/@scope/pkg", version: "1.2.3", name: "@scope/alias" }, + { path: "node_modules/alias-scoped", version: "1.2.3", name: "@scope/pkg" } + ], { + dependencies: { + "@scope/pkg": "1.2.3" + } + }); + + const authority = await resolveProjectAuthorityForTest(project); + assert.ok(authority); + const result = await resolvePackageDescriptor({ + kind: "package" as const, + manifest_sha256: aliasManifest, + modes: ["runtime"] as const, + package_name: "@scope/pkg", + package_path: "./src/index.ts", + package_version: "1.2.3", + sha256: aliasSource + }, [authority]); + + assert.equal(result.lockEntryPath, "node_modules/alias-scoped"); + }); +}); + +test("type-only claim rejects non-portable path prefixes", async () => { + const authority = await resolveToolchainAuthorityFromAnchor(fileURLToPath(import.meta.url), TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION); + + await assert.rejects( + () => buildTypeOnlyClaim({ + kind: "type-only", + surface: "dynamics", + package_name: "simfile" as const, + package_version: authority.root_package_version, + manifest_sha256: authority.root_package_sha256, + files: [{ path: "./file:", sha256: "000000000000000000000000000000000000000000000000000000000000000000" }] + }, authority), + /unsafe path/ + ); + + await assert.rejects( + () => buildTypeOnlyClaim({ + kind: "type-only", + surface: "dynamics", + package_name: "simfile" as const, + package_version: authority.root_package_version, + manifest_sha256: authority.root_package_sha256, + files: [{ path: "./C:", sha256: "000000000000000000000000000000000000000000000000000000000000000000" }] + }, authority), + /unsafe path/ + ); + + await assert.rejects( + () => buildTypeOnlyClaim({ + kind: "type-only", + surface: "dynamics", + package_name: "simfile" as const, + package_version: authority.root_package_version, + manifest_sha256: authority.root_package_sha256, + files: [{ path: "./file:payload", sha256: "0000000000000000000000000000000000000000000000000000000000000000" }] + }, authority), + /unsafe path/ + ); +}); + +test("portable paths reject DEL and C1 controls", () => { + assert.throws(() => assertPortablePath(`file${String.fromCodePoint(0x7f)}name`, "DEL path"), /unsafe path/); + assert.throws(() => assertPortablePath(`file${String.fromCodePoint(0x85)}name`, "C1 path"), /unsafe path/); +}); + +test("duplicate lock authorities keep the toolchain authority", async () => { + await withTemp(async (root) => { + const outer = path.join(root, "outer"); + await createSimfile(outer); + await writeJson(path.join(outer, "package.json"), { name: "project", version: "1.0.0" }); + await createLockFile(outer, "project", "1.0.0", [], {}); + + const projectAuthority = await resolveProjectAuthorityForTest(outer); + assert.ok(projectAuthority); + const lockRealPath = await realpath(projectAuthority.absoluteLockPath); + const toolchainAuthority = await resolveToolchainAuthorityFromAnchor(fileURLToPath(import.meta.url), TOOLCHAIN_ESBUILD_VERSION, TOOLCHAIN_TYPESCRIPT_VERSION); + const overlappingToolchainAuthority = { + ...toolchainAuthority, + absoluteLockRoot: outer, + absoluteLockPath: projectAuthority.absoluteLockPath, + absoluteLockRealPath: lockRealPath + }; + + const merged = coalesceAuthoritiesByLockRealpath([projectAuthority, overlappingToolchainAuthority]); + assert.equal(merged.length, 1); + assert.equal(merged[0].kind, "toolchain"); + }); +}); diff --git a/src/dynamics/buildReceiptLockAuthority.test.ts b/src/dynamics/buildReceiptLockAuthority.test.ts new file mode 100644 index 0000000..fde4f88 --- /dev/null +++ b/src/dynamics/buildReceiptLockAuthority.test.ts @@ -0,0 +1,230 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; +import { buildReceiptLock } from "./buildReceiptLock.js"; +import { + parseLockAuthorityBytes, + resolveToolchainAuthorityFromAnchor, + type DynamicsReceiptLockToolchainAuthority +} from "./buildReceiptLockAuthority.js"; +import { + resolvePackageDescriptor, + resolveProjectAuthority +} from "./buildReceiptLockFiles.js"; +import { + createLockFile, + createSimfile, + createSymlinkDirectory, + readFileDigest, + withTemp, + writeJson +} from "./buildReceiptLock.test-helper.js"; + +const ESBUILD_VERSION = "0.28.1"; +const TYPESCRIPT_VERSION = "5.9.3"; + +const toolchainAuthority = (): Promise => + resolveToolchainAuthorityFromAnchor(fileURLToPath(import.meta.url), ESBUILD_VERSION, TYPESCRIPT_VERSION); + +const strictBytes = (entries: Record): readonly [Uint8Array, Uint8Array] => { + const packageRaw = new TextEncoder().encode(JSON.stringify({ name: "simfile", version: "0.0.1" })); + const lockRaw = new TextEncoder().encode(JSON.stringify({ + name: "simfile", + version: "0.0.1", + lockfileVersion: 3, + packages: { + "": { name: "simfile", version: "0.0.1" }, + ...entries + } + })); + return [packageRaw, lockRaw]; +}; + +const writeSelfLinkProject = async ( + project: string, + authority: DynamicsReceiptLockToolchainAuthority, + mutateEntries?: (entries: Array<{ + path: string; + version?: string; + name?: string; + resolved?: string; + link?: boolean; + }>, resolved: string) => void, + installedTarget = authority.absoluteRoot +): Promise<{ simfile: string; resolved: string }> => { + const simfile = await createSimfile(project); + const resolved = path.relative(project, authority.absoluteRoot).split(path.sep).join("/"); + await writeJson(path.join(project, "package.json"), { + name: "project", + version: "1.0.0", + dependencies: { simfile: `file:${resolved}` } + }); + const entries = [ + { path: resolved, version: authority.root_package_version }, + { path: "node_modules/simfile", resolved, link: true } + ]; + mutateEntries?.(entries, resolved); + await createLockFile(project, "project", "1.0.0", entries, { + dependencies: { simfile: `file:${resolved}` } + }); + await createSymlinkDirectory(installedTarget, path.join(project, "node_modules", "simfile")); + return { simfile, resolved }; +}; + +test("strict toolchain parser rejects links and traversal entries", () => { + const [linkPackage, linkLock] = strictBytes({ + "node_modules/simfile": { resolved: "../../..", link: true } + }); + assert.throws( + () => parseLockAuthorityBytes("/strict", linkPackage, linkLock), + /workspace lock entry rejected: node_modules\/simfile/ + ); + + const [sourcePackage, sourceLock] = strictBytes({ + "../../..": { version: "0.0.1" } + }); + assert.throws( + () => parseLockAuthorityBytes("/strict", sourcePackage, sourceLock), + /lock entry \.\.\/\.\.\/\.\.: unsafe path/ + ); +}); + +test("project parser accepts exactly one bound Simfile self-link pair", async () => { + await withTemp(async (root) => { + const authority = await toolchainAuthority(); + const project = path.join(root, "project"); + const { simfile, resolved } = await writeSelfLinkProject(project, authority); + const projectAuthority = await resolveProjectAuthority(project, authority); + assert.ok(projectAuthority); + assert.equal(projectAuthority.packageEntries.some((entry) => + entry.path === resolved || entry.path === "node_modules/simfile" + ), false); + assert.deepEqual(projectAuthority.selfLinkEntries, [{ + manager: "npm", + lockfile_version: 3, + lock_sha256: await readFileDigest(path.join(project, "package-lock.json")), + lock_entry_path: "node_modules/simfile", + package_name: "simfile", + package_version: authority.root_package_version, + package_manifest_sha256: authority.root_package_sha256, + target: "toolchain_authority_root" + }]); + assert.equal(Object.isFrozen(projectAuthority.selfLinkEntries), true); + assert.equal(Object.isFrozen(projectAuthority.selfLinkEntries[0]), true); + + const result = await buildReceiptLock(simfile, [], ESBUILD_VERSION, TYPESCRIPT_VERSION); + assert.deepEqual(result.selfLinkEntries, projectAuthority.selfLinkEntries); + const serialized = JSON.stringify(result.selfLinkEntries); + assert.equal(serialized.includes(resolved), false); + assert.equal(serialized.includes(authority.absoluteRoot), false); + + const sourceSha256 = await readFileDigest(path.join(authority.absoluteRoot, "package.json")); + await assert.rejects( + () => resolvePackageDescriptor({ + kind: "package", + manifest_sha256: authority.root_package_sha256, + modes: ["runtime"], + package_name: "simfile", + package_path: "./package.json", + package_version: authority.root_package_version, + sha256: sourceSha256 + }, [projectAuthority]), + /missing package lock evidence: simfile/ + ); + }); +}); + +test("project self-link rejects a target outside the toolchain authority", async () => { + await withTemp(async (root) => { + const authority = await toolchainAuthority(); + const other = path.join(root, "other"); + await mkdir(other); + const installedMismatch = path.join(root, "installed-mismatch"); + await writeSelfLinkProject(installedMismatch, authority, undefined, other); + await assert.rejects( + () => resolveProjectAuthority(installedMismatch, authority), + /Simfile self-link target authority mismatch/ + ); + + const sourceMismatch = path.join(root, "source-mismatch"); + await createSimfile(sourceMismatch); + const resolved = "../other"; + await writeJson(path.join(sourceMismatch, "package.json"), { + name: "project", + version: "1.0.0", + dependencies: { simfile: `file:${resolved}` } + }); + await createLockFile(sourceMismatch, "project", "1.0.0", [ + { path: resolved, version: authority.root_package_version }, + { path: "node_modules/simfile", resolved, link: true } + ], { dependencies: { simfile: `file:${resolved}` } }); + await createSymlinkDirectory(authority.absoluteRoot, path.join(sourceMismatch, "node_modules", "simfile")); + await assert.rejects( + () => resolveProjectAuthority(sourceMismatch, authority), + /Simfile self-link target authority mismatch/ + ); + }); +}); + +test("project self-link rejects extra, unpaired, absolute, and malformed records", async () => { + const authority = await toolchainAuthority(); + await withTemp(async (root) => { + const extraProject = path.join(root, "extra"); + await writeSelfLinkProject(extraProject, authority, (entries) => { + entries.push({ path: "node_modules/other", resolved: "../other", link: true }); + }); + await assert.rejects( + () => resolveProjectAuthority(extraProject, authority), + /invalid Simfile self-link entry/ + ); + + const unpairedProject = path.join(root, "unpaired"); + await createSimfile(unpairedProject); + await writeJson(path.join(unpairedProject, "package.json"), { name: "project", version: "1.0.0" }); + await createLockFile(unpairedProject, "project", "1.0.0", [{ path: "../../..", version: "0.0.1" }]); + await assert.rejects( + () => resolveProjectAuthority(unpairedProject, authority), + /invalid Simfile self-link entry/ + ); + + for (const [index, target] of [ + "/absolute/simfile", + "https://example.invalid/simfile", + "..\\..\\simfile", + "../../simfile?query", + "../../simfile#fragment", + `../simfile${String.fromCodePoint(0x7f)}` + ].entries()) { + const project = path.join(root, `malformed-${index}`); + await createSimfile(project); + await writeJson(path.join(project, "package.json"), { + name: "project", + version: "1.0.0", + dependencies: { simfile: `file:${target}` } + }); + await createLockFile(project, "project", "1.0.0", [ + { path: target, version: authority.root_package_version }, + { path: "node_modules/simfile", resolved: target, link: true } + ], { dependencies: { simfile: `file:${target}` } }); + await assert.rejects( + () => resolveProjectAuthority(project, authority), + /invalid Simfile self-link target/ + ); + } + }); +}); + +test("root lock stays strict and retains one integrity-pinned Stele package", async () => { + const lock = JSON.parse(await readFile(path.join(process.cwd(), "package-lock.json"), "utf8")) as { + packages: Record; + }; + const entries = Object.entries(lock.packages); + assert.equal(entries.some(([entryPath, entry]) => + (entryPath !== "" && !entryPath.startsWith("node_modules/")) || entry.link === true + ), false); + const steleEntries = entries.filter(([entryPath]) => entryPath === "node_modules/@noopolis/stele"); + assert.equal(steleEntries.length, 1); + assert.equal(typeof steleEntries[0]?.[1].integrity, "string"); +}); diff --git a/src/dynamics/buildReceiptLockAuthority.ts b/src/dynamics/buildReceiptLockAuthority.ts new file mode 100644 index 0000000..ba6db8b --- /dev/null +++ b/src/dynamics/buildReceiptLockAuthority.ts @@ -0,0 +1,343 @@ +import { createRequire } from "node:module"; +import { lstat, realpath } from "node:fs/promises"; +import path from "node:path"; +import { compareUtf16, deepFreeze } from "./buildIdentity.js"; +import { + asBoolean, + asDefined, + asDependencyMap, + asObject, + asString, + assertPortablePath, + assertRegularDirectory, + assertRegularFile, + compareMaps, + parseJson, + readPackageIdentity, + readPackageName, + readRegularFile, + sha256, + toPortable +} from "./buildReceiptLockPath.js"; +import type { DynamicsReceiptSelfLinkEntry } from "./buildReceiptSelfLinks.js"; + +const fail = (message: string): never => { throw new Error(message); }; + +const isNodeModulesPath = (value: string): boolean => value === "" || value.startsWith("node_modules/"); + +export interface LockPackageEntry { + readonly path: string; + readonly version: string; + readonly name: string | null; +} + +export interface LockAuthorityParse { + readonly rootName: string; + readonly rootVersion: string; + readonly packageEntries: readonly LockPackageEntry[]; + readonly lockPath: string; + readonly lockDigest: string; + readonly rootPackageSha256: string; + readonly rootDependencies: { + readonly dependencies: Readonly>; + readonly devDependencies: Readonly>; + readonly optionalDependencies: Readonly>; + readonly peerDependencies: Readonly>; + }; +} + +export interface DynamicsReceiptLockToolIdentity { + readonly name: "esbuild" | "typescript"; + readonly version: string; + readonly manifest_sha256: string; + readonly lock_entry_path: string; + readonly lock_sha256: string; +} + +type LockAuthorityKind = "toolchain" | "project"; +interface LockAuthorityBase { + readonly kind: LockAuthorityKind; + readonly absoluteRoot: string; + readonly absoluteLockRoot: string; + readonly absoluteLockPath: string; + readonly absoluteLockRealPath: string; + readonly lock_sha256: string; + readonly root_package_name: string; + readonly root_package_version: string; + readonly root_package_sha256: string; + readonly packageEntries: readonly LockPackageEntry[]; + readonly toolIdentities: readonly DynamicsReceiptLockToolIdentity[] | null; +} + +export type DynamicsReceiptLockToolchainAuthority = Readonly; +export type DynamicsReceiptLockProjectAuthority = Readonly; + +type LockAuthority = DynamicsReceiptLockToolchainAuthority | DynamicsReceiptLockProjectAuthority; + +const asEntry = (value: unknown, lockPath: string, entryPath: string): LockPackageEntry => { + const json = asObject(value, `${lockPath}:packages[${JSON.stringify(entryPath)}]`); + if (entryPath !== "") { + assertPortablePath(entryPath, `lock entry ${entryPath}`); + if (!isNodeModulesPath(entryPath)) fail(`invalid lock entry path: ${entryPath}`); + } + + if (asBoolean(json.link, `${lockPath}:packages[${JSON.stringify(entryPath)}].link`) === true) { + fail(`workspace lock entry rejected: ${entryPath}`); + } + + const entryName = Object.prototype.hasOwnProperty.call(json, "name") + ? asString(json.name, `${lockPath}:packages[${JSON.stringify(entryPath)}].name`) + : undefined; + const entryVersion = asString(json.version, `${lockPath}:packages[${JSON.stringify(entryPath)}].version`); + return deepFreeze({ path: entryPath, version: entryVersion, name: entryName ?? null }); +}; + +/** Parses the exact package and lock bytes retained by a caller-owned snapshot. */ +export const parseLockAuthorityBytes = ( + root: string, + packageJsonRaw: Uint8Array, + lockRaw: Uint8Array, +): LockAuthorityParse => { + const packageJsonPath = path.join(root, "package.json"); + const lockPath = path.join(root, "package-lock.json"); + + const packageJson = parseJson(new TextDecoder("utf-8", { fatal: true }).decode(packageJsonRaw), packageJsonPath); + const rootName = asString(packageJson.name, `${packageJsonPath}:name`); + const rootVersion = asString(packageJson.version, `${packageJsonPath}:version`); + const packageJsonSha = sha256(packageJsonRaw); + const packageJsonDeps = { + dependencies: asDependencyMap(packageJson.dependencies, `${packageJsonPath}:dependencies`), + devDependencies: asDependencyMap(packageJson.devDependencies, `${packageJsonPath}:devDependencies`), + optionalDependencies: asDependencyMap(packageJson.optionalDependencies, `${packageJsonPath}:optionalDependencies`), + peerDependencies: asDependencyMap(packageJson.peerDependencies, `${packageJsonPath}:peerDependencies`) + }; + + const lock = parseJson(new TextDecoder("utf-8", { fatal: true }).decode(lockRaw), lockPath); + if (lock.lockfileVersion !== 3) fail(`unsupported npm lockfile version: ${lockPath}`); + + const lockName = asString(lock.name, `${lockPath}:name`); + const lockVersion = asString(lock.version, `${lockPath}:version`); + if (lockName !== rootName || lockVersion !== rootVersion) fail(`top-level lock mismatch: ${lockPath}`); + + const packageEntriesRaw = asObject(lock.packages, `${lockPath}:packages`); + if (!("" in packageEntriesRaw)) fail(`invalid package-lock packages entry: ${lockPath}`); + + const rootEntry = asObject(packageEntriesRaw[""], `${lockPath}:packages[\"\"]`); + const rootEntryName = asString(rootEntry.name, `${lockPath}:packages[\"\"].name`); + const rootEntryVersion = asString(rootEntry.version, `${lockPath}:packages[\"\"].version`); + if (rootEntryName !== rootName || rootEntryVersion !== rootVersion) fail(`wrong-root lock data: ${lockPath}`); + + const lockDependencyMaps = { + dependencies: asDependencyMap(rootEntry.dependencies, `${lockPath}:packages[\"\"].dependencies`), + devDependencies: asDependencyMap(rootEntry.devDependencies, `${lockPath}:packages[\"\"].devDependencies`), + optionalDependencies: asDependencyMap(rootEntry.optionalDependencies, `${lockPath}:packages[\"\"].optionalDependencies`), + peerDependencies: asDependencyMap(rootEntry.peerDependencies, `${lockPath}:packages[\"\"].peerDependencies`) + }; + + compareMaps(packageJsonDeps.dependencies, lockDependencyMaps.dependencies, "dependencies"); + compareMaps(packageJsonDeps.devDependencies, lockDependencyMaps.devDependencies, "devDependencies"); + compareMaps(packageJsonDeps.optionalDependencies, lockDependencyMaps.optionalDependencies, "optionalDependencies"); + compareMaps(packageJsonDeps.peerDependencies, lockDependencyMaps.peerDependencies, "peerDependencies"); + + const packageEntries: Array = []; + for (const entryPath of Object.keys(packageEntriesRaw).sort(compareUtf16)) { + if (entryPath === "") continue; + packageEntries.push(asEntry(packageEntriesRaw[entryPath], lockPath, entryPath)); + } + + return deepFreeze({ + rootName, + rootVersion, + packageEntries: deepFreeze(packageEntries), + lockPath, + lockDigest: sha256(lockRaw), + rootPackageSha256: packageJsonSha, + rootDependencies: { + dependencies: packageJsonDeps.dependencies, + devDependencies: packageJsonDeps.devDependencies, + optionalDependencies: packageJsonDeps.optionalDependencies, + peerDependencies: packageJsonDeps.peerDependencies + } + }); +}; + +export const readLockAuthorityParse = async (root: string): Promise => + parseLockAuthorityBytes( + root, + await readRegularFile(path.join(root, "package.json"), path.join(root, "package.json")), + await readRegularFile(path.join(root, "package-lock.json"), path.join(root, "package-lock.json")), + ); + +interface ResolvedTool { + readonly name: "esbuild" | "typescript"; + readonly version: string; + readonly packageRootRealPath: string; + readonly manifestRealPath: string; + readonly manifestSha256: string; +} + +const relativeToolPath = (root: string, tool: ResolvedTool): string => { + const rel = path.relative(root, tool.packageRootRealPath); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) fail(`tool package root mismatch: ${tool.name}`); + return toPortable(rel); +}; + +const resolveToolFromAnchor = async ( + require: NodeRequire, + name: "esbuild" | "typescript", + expectedVersion: string +): Promise => { + const packageEntryPath = require.resolve(name); + const packageManifestPath = require.resolve(`${name}/package.json`); + const absoluteManifestPath = await assertRegularFile(packageManifestPath, `${name}/package.json`); + const absoluteEntryPath = await assertRegularFile(packageEntryPath, `${name}`); + + const entryRelative = path.relative(path.dirname(absoluteManifestPath), absoluteEntryPath); + if (entryRelative === "" || entryRelative.startsWith("..") || path.isAbsolute(entryRelative)) { + fail(`tool package path mismatch: ${name}`); + } + + const manifest = await readPackageIdentity(absoluteManifestPath); + if (manifest.name !== name || manifest.version !== expectedVersion) fail(`toolchain installed ${name} mismatch`); + + const manifestRealPath = await realpath(absoluteManifestPath); + return { + name, + version: manifest.version, + packageRootRealPath: await realpath(path.dirname(manifestRealPath)), + manifestRealPath, + manifestSha256: manifest.sha256 + }; +}; + +const readToolIdentity = ( + root: string, + tool: ResolvedTool, + entryPath: string, + lockDigest: string +): DynamicsReceiptLockToolIdentity => { + const relativeManifest = toPortable(path.relative(root, tool.manifestRealPath)); + if (relativeManifest !== `${entryPath}/package.json`) { + fail(`toolchain manifest mismatch: ${tool.name}`); + } + return { + name: tool.name, + version: tool.version, + manifest_sha256: tool.manifestSha256, + lock_entry_path: entryPath, + lock_sha256: lockDigest + }; +}; + +const resolveToolchainLockAuthority = async ( + root: string, + esbuild: ResolvedTool, + typescript: ResolvedTool +): Promise => { + const parsed = await readLockAuthorityParse(root); + if (parsed.rootName !== "simfile") fail("toolchain lock root mismatch"); + + const rootDependency = { + ...parsed.rootDependencies.dependencies, + ...parsed.rootDependencies.devDependencies, + ...parsed.rootDependencies.optionalDependencies, + ...parsed.rootDependencies.peerDependencies + }; + if (rootDependency.esbuild !== esbuild.version) fail("toolchain root esbuild pin mismatch"); + if (rootDependency.typescript !== typescript.version) fail("toolchain root typescript pin mismatch"); + + const esbuildEntryPath = relativeToolPath(root, esbuild); + const typescriptEntryPath = relativeToolPath(root, typescript); + + const esbuildPackagePath = await assertRegularDirectory(path.join(root, esbuildEntryPath), `toolchain lock entry ${esbuild.name}`); + const typescriptPackagePath = await assertRegularDirectory(path.join(root, typescriptEntryPath), `toolchain lock entry ${typescript.name}`); + if (await realpath(esbuildPackagePath) !== esbuild.packageRootRealPath) fail(`toolchain lock package mismatch: ${esbuild.name}`); + if (await realpath(typescriptPackagePath) !== typescript.packageRootRealPath) fail(`toolchain lock package mismatch: ${typescript.name}`); + + const esbuildEntry = asDefined(parsed.packageEntries.find((entry) => entry.path === esbuildEntryPath), `toolchain lock entry mismatch: ${esbuild.name}`); + const typescriptEntry = asDefined( + parsed.packageEntries.find((entry) => entry.path === typescriptEntryPath), + `toolchain lock entry mismatch: ${typescript.name}` + ); + if (esbuildEntry.version !== esbuild.version) fail(`toolchain lock entry esbuild mismatch`); + if (typescriptEntry.version !== typescript.version) fail(`toolchain lock entry typescript mismatch`); + if (esbuildEntry.name !== null && esbuildEntry.name !== esbuild.name) fail(`toolchain lock entry name mismatch: ${esbuild.name}`); + if (typescriptEntry.name !== null && typescriptEntry.name !== typescript.name) fail(`toolchain lock entry name mismatch: ${typescript.name}`); + + return deepFreeze({ + kind: "toolchain", + absoluteRoot: root, + absoluteLockRoot: root, + absoluteLockPath: parsed.lockPath, + absoluteLockRealPath: await realpath(parsed.lockPath), + lock_sha256: parsed.lockDigest, + root_package_name: parsed.rootName, + root_package_version: parsed.rootVersion, + root_package_sha256: parsed.rootPackageSha256, + packageEntries: parsed.packageEntries, + toolIdentities: deepFreeze([ + readToolIdentity(root, esbuild, esbuildEntry.path, parsed.lockDigest), + readToolIdentity(root, typescript, typescriptEntry.path, parsed.lockDigest) + ]) + }); +}; + +const existsRegularFile = async (filePath: string, label: string): Promise => { + try { + const entry = await lstat(filePath); + if (entry.isSymbolicLink()) fail(`forbidden symlink file: ${label}`); + if (!entry.isFile()) fail(`non-regular file: ${label}`); + return true; + } catch (error: unknown) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +}; + +const hasPair = async (root: string): Promise<{ hasPackageJson: boolean; hasLockFile: boolean }> => ({ + hasPackageJson: await existsRegularFile(path.join(root, "package.json"), `${root}/package.json`), + hasLockFile: await existsRegularFile(path.join(root, "package-lock.json"), `${root}/package-lock.json`) +}); + +const intersectCommonAncestors = (left: string, right: string): string[] => { + const rightAncestors = new Set(); + for (let root = right; ; root = path.dirname(root)) { + rightAncestors.add(root); + const next = path.dirname(root); + if (next === root) break; + } + const output: string[] = []; + for (let root = left; ; root = path.dirname(root)) { + if (rightAncestors.has(root) && !output.includes(root)) output.push(root); + const next = path.dirname(root); + if (next === root) break; + } + return output; +}; + +export const resolveToolchainAuthorityFromAnchor = async ( + anchorPath: string, + esbuildVersion: string, + typescriptVersion: string +): Promise => { + const require = createRequire(anchorPath); + const esbuild = await resolveToolFromAnchor(require, "esbuild", esbuildVersion); + const typescript = await resolveToolFromAnchor(require, "typescript", typescriptVersion); + + const candidateRoots = intersectCommonAncestors(esbuild.packageRootRealPath, typescript.packageRootRealPath); + for (const candidateRoot of candidateRoots) { + const { hasPackageJson, hasLockFile } = await hasPair(candidateRoot); + if (!hasPackageJson && !hasLockFile) continue; + if (!hasPackageJson) fail(`partial toolchain authority at ${candidateRoot}`); + + if (await readPackageName(candidateRoot) !== "simfile") continue; + if (!hasLockFile) fail(`partial toolchain authority at ${candidateRoot}`); + return resolveToolchainLockAuthority(candidateRoot, esbuild, typescript); + } + + return fail("no toolchain authority found"); +}; diff --git a/src/dynamics/buildReceiptLockFiles.ts b/src/dynamics/buildReceiptLockFiles.ts new file mode 100644 index 0000000..d28ecb6 --- /dev/null +++ b/src/dynamics/buildReceiptLockFiles.ts @@ -0,0 +1,197 @@ +import { lstat, realpath } from "node:fs/promises"; +import path from "node:path"; +import { deepFreeze, type DynamicsBuildInputDescriptor } from "./buildIdentity.js"; +import { + assertPortablePath, + assertRegularDirectory, + assertRegularFile, + ensureNoSymlink, + readPackageIdentity, + readRegularFile, + sha256 +} from "./buildReceiptLockPath.js"; +import { + resolveToolchainAuthorityFromAnchor, + type DynamicsReceiptLockProjectAuthority, + type DynamicsReceiptLockToolchainAuthority, + type DynamicsReceiptLockToolIdentity +} from "./buildReceiptLockAuthority.js"; +import { readProjectLockAuthorityParse } from "./buildReceiptProjectLockAuthority.js"; + +const fail = (message: string): never => { throw new Error(message); }; + +const inferPackageNameFromPath = (entryPath: string): string | null => { + if (!entryPath.startsWith("node_modules/")) return null; + const segments = entryPath.slice("node_modules/".length).split("/").filter(Boolean); + if (segments.length === 0) return null; + const tail = segments.at(-1); + if (tail === undefined || tail === "node_modules") return null; + const previous = segments.at(-2); + if (tail.startsWith("@")) return null; + if (previous?.startsWith("@")) return `${previous}/${tail}`; + return tail; +}; + +export const resolveSimfileProjectRoot = async (absoluteSimfilePath: string): Promise => { + const simfilePath = await assertRegularFile(absoluteSimfilePath, "simfile"); + const projectRoot = path.dirname(simfilePath); + return realpath(await assertRegularDirectory(projectRoot, "project root")); +}; + +const existsRegularFile = async (filePath: string, label: string): Promise => { + try { + const entry = await lstat(filePath); + if (entry.isSymbolicLink()) fail(`forbidden symlink file: ${label}`); + if (!entry.isFile()) fail(`non-regular file: ${label}`); + return true; + } catch (error: unknown) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +}; + +const hasPair = async (root: string): Promise<{ hasPackageJson: boolean; hasLockFile: boolean }> => ({ + hasPackageJson: await existsRegularFile(path.join(root, "package.json"), `${root}/package.json`), + hasLockFile: await existsRegularFile(path.join(root, "package-lock.json"), `${root}/package-lock.json`) +}); + +export const resolveProjectAuthority = async ( + projectRoot: string, + toolchainAuthority: DynamicsReceiptLockToolchainAuthority +): Promise => { + const root = await assertRegularDirectory(projectRoot, "project root"); + const { hasPackageJson, hasLockFile } = await hasPair(root); + if (!hasPackageJson && !hasLockFile) return null; + if (!hasPackageJson || !hasLockFile) fail(`partial project authority at ${root}`); + + const parsed = await readProjectLockAuthorityParse(root, toolchainAuthority); + return deepFreeze({ + kind: "project", + absoluteRoot: root, + absoluteLockRoot: root, + absoluteLockPath: parsed.lockPath, + absoluteLockRealPath: await realpath(parsed.lockPath), + lock_sha256: parsed.lockDigest, + root_package_name: parsed.rootName, + root_package_version: parsed.rootVersion, + root_package_sha256: parsed.rootPackageSha256, + packageEntries: parsed.packageEntries, + toolIdentities: null, + selfLinkEntries: parsed.selfLinkEntries + }); +}; + +export const resolvePackageDescriptor = async ( + descriptor: Extract, + authorities: readonly (DynamicsReceiptLockToolchainAuthority | DynamicsReceiptLockProjectAuthority)[] +): Promise<{ authority: DynamicsReceiptLockToolchainAuthority | DynamicsReceiptLockProjectAuthority; lockEntryPath: string }> => { + const assertDescriptorPath = (value: string): string => { + if (!value.startsWith("./")) fail(`path must start ./: ${value}`); + const portable = value.slice(2); + if (portable.length === 0) fail(`empty descriptor path: ${value}`); + assertPortablePath(portable, `path ${value}`); + return portable; + }; + + const sourcePath = assertDescriptorPath(descriptor.package_path); + const matches: Array<{ authority: DynamicsReceiptLockToolchainAuthority | DynamicsReceiptLockProjectAuthority; lockEntryPath: string }> = []; + + for (const authority of authorities) { + for (const entry of authority.packageEntries) { + if (entry.path === "") continue; + const inferredPackageName = inferPackageNameFromPath(entry.path); + const lockPackageName = entry.name; + const descriptorPackageName = descriptor.package_name; + + if (lockPackageName !== null && lockPackageName !== descriptorPackageName) continue; + if (lockPackageName === null && inferredPackageName !== descriptorPackageName) continue; + if (entry.version !== descriptor.package_version) continue; + + const manifestPath = path.join(authority.absoluteRoot, entry.path, "package.json"); + const manifest = await readPackageIdentity(manifestPath); + if ( + manifest.name !== descriptorPackageName || + manifest.version !== descriptor.package_version || + manifest.sha256 !== descriptor.manifest_sha256 + ) { + continue; + } + + const absoluteSource = ensureNoSymlink(authority.absoluteRoot, path.join(entry.path, sourcePath), `package source ${descriptor.package_path}`); + const rel = path.relative(authority.absoluteRoot, absoluteSource); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) fail(`path escapes root: package source ${descriptor.package_path}`); + const sourceSha = sha256(await readRegularFile(absoluteSource, `package source ${descriptor.package_path}`)); + if (sourceSha !== descriptor.sha256) continue; + matches.push({ authority, lockEntryPath: entry.path }); + } + } + + if (matches.length === 0) fail(`missing package lock evidence: ${descriptor.package_name}`); + if (matches.length > 1) fail(`ambiguous package lock evidence: ${descriptor.package_name}`); + return matches[0]; +}; + +export const buildTypeOnlyClaim = async ( + descriptor: Extract, + toolchainAuthority: DynamicsReceiptLockToolchainAuthority +) => { + if (descriptor.surface !== "dynamics") fail(`unsupported type-only surface: ${descriptor.surface}`); + if (descriptor.package_name !== toolchainAuthority.root_package_name) fail("type-only package mismatch"); + if (descriptor.package_version !== toolchainAuthority.root_package_version) fail("type-only version mismatch"); + + const manifestPath = path.join(toolchainAuthority.absoluteRoot, "package.json"); + const manifestSha256 = sha256(await readRegularFile(manifestPath, "type-only package manifest")); + if (manifestSha256 !== descriptor.manifest_sha256) fail("type-only manifest mismatch"); + if (descriptor.files.length === 0) fail("type-only files must be non-empty"); + + const seen = new Set(); + const source_digests: Array<{ path: string; sha256: string }> = []; + const sortedFiles: string[] = []; + for (const file of descriptor.files) { + const assertDescriptorPath = (value: string): string => { + if (!value.startsWith("./")) fail(`path must start ./: ${value}`); + const portable = value.slice(2); + if (portable.length === 0) fail(`empty descriptor path: ${value}`); + assertPortablePath(portable, `path ${value}`); + return portable; + }; + + const portable = assertDescriptorPath(file.path); + if (seen.has(portable)) fail(`type-only duplicate file: ${file.path}`); + if (source_digests.length > 0) { + const previousPortable = sortedFiles[sortedFiles.length - 1]; + if (previousPortable >= portable) fail("type-only file list not canonical"); + } + seen.add(portable); + sortedFiles.push(portable); + + const absolute = ensureNoSymlink(toolchainAuthority.absoluteRoot, portable, `type-only file ${file.path}`); + const rel = path.relative(toolchainAuthority.absoluteRoot, absolute); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) fail(`path escapes root: type-only file ${file.path}`); + const hash = sha256(await readRegularFile(absolute, `type-only file ${file.path}`)); + if (hash !== file.sha256) fail(`type-only source hash mismatch: ${file.path}`); + source_digests.push({ path: `./${portable}`, sha256: hash }); + } + + return deepFreeze({ + manager: "npm", + lockfile_version: 3, + root_package_name: toolchainAuthority.root_package_name, + root_package_version: toolchainAuthority.root_package_version, + root_package_sha256: toolchainAuthority.root_package_sha256, + lock_sha256: toolchainAuthority.lock_sha256, + lock_entry_path: "", + package_name: descriptor.package_name, + package_version: descriptor.package_version, + package_manifest_sha256: descriptor.manifest_sha256, + source_digests: deepFreeze(source_digests), + tool_identities: toolchainAuthority.toolIdentities + }); +}; + +export { + resolveToolchainAuthorityFromAnchor, + type DynamicsReceiptLockProjectAuthority, + type DynamicsReceiptLockToolchainAuthority, + type DynamicsReceiptLockToolIdentity +}; diff --git a/src/dynamics/buildReceiptLockPath.ts b/src/dynamics/buildReceiptLockPath.ts new file mode 100644 index 0000000..2bc03dd --- /dev/null +++ b/src/dynamics/buildReceiptLockPath.ts @@ -0,0 +1,160 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile } from "node:fs/promises"; +import path from "node:path"; +import { compareUtf16 } from "./buildIdentity.js"; + +const fail = (message: string): never => { throw new Error(message); }; + +export const sha256 = (value: string | Uint8Array): string => createHash("sha256").update(value).digest("hex"); + +export const compareMaps = (left: Record, right: Record, label: string): void => { + const leftEntries = Object.entries(left).sort(([leftKey], [rightKey]) => compareUtf16(leftKey, rightKey)); + const rightEntries = Object.entries(right).sort(([leftKey], [rightKey]) => compareUtf16(leftKey, rightKey)); + if (leftEntries.length !== rightEntries.length) fail(`lock root dependency drift: ${label}`); + for (let index = 0; index < leftEntries.length; index += 1) { + const [leftKey, leftValue] = leftEntries[index]; + const [rightKey, rightValue] = rightEntries[index]; + if (leftKey !== rightKey || leftValue !== rightValue) fail(`lock root dependency drift: ${label}`); + } +}; + +export const asObject = (value: unknown, label: string): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) fail(`${label}: expected object`); + return value as Record; +}; + +export const asString = (value: unknown, label: string): string => { + const text = typeof value === "string" ? value : fail(`${label}: expected non-empty string`); + if (text.length === 0) fail(`${label}: expected non-empty string`); + return text; +}; + +export const asBoolean = (value: unknown, label: string): boolean | null => { + if (value === undefined) return null; + if (typeof value === "boolean") return value; + fail(`${label}: expected boolean`); + return null; +}; + +export const asDefined = (value: T | undefined, label: string): T => { + if (value === undefined) fail(label); + return value as T; +}; + +export const asDependencyMap = (value: unknown, label: string): Record => { + if (value === undefined) return {}; + if (value === null || typeof value !== "object" || Array.isArray(value)) fail(`${label}: expected map`); + + const output: Record = {}; + for (const [key, raw] of Object.entries(value as Record)) { + output[key] = asString(raw, `${label}.${key}`); + } + return output; +}; + +export const parseJson = (value: string, filePath: string): Record => { + try { + return asObject(JSON.parse(value), filePath); + } catch (_error) { + return fail(`malformed JSON: ${filePath}`); + } +}; + +const hasControlCharacter = (value: string): boolean => { + for (const char of value) { + const code = char.codePointAt(0); + if (code === undefined || code <= 0x1f || (code >= 0x7f && code <= 0x9f)) return true; + } + return false; +}; + +const isPortablePathSegment = (value: string): boolean => { + if (/^[A-Za-z][A-Za-z0-9+.-]*:/.test(value)) return true; + if (/^[A-Za-z]:$/.test(value)) return true; + return false; +}; + +export const assertPortablePath = (value: string, label: string): void => { + if (value.includes("\\")) fail(`${label}: unsafe path`); + if (value.includes("\0")) fail(`${label}: unsafe path`); + if (hasControlCharacter(value)) fail(`${label}: unsafe path`); + if (value.includes("?")) fail(`${label}: unsafe path`); + if (value.includes("#")) fail(`${label}: unsafe path`); + if (value.includes("://")) fail(`${label}: unsafe path`); + if (value.length === 0) fail(`${label}: empty path`); + if (path.isAbsolute(value)) fail(`${label}: absolute path`); + + for (const segment of value.split("/")) { + if (segment === "" || segment === "." || segment === "..") fail(`${label}: unsafe path`); + if (isPortablePathSegment(segment)) fail(`${label}: unsafe path`); + } +}; + +export const assertRegularPath = async (value: string, label: string, expectsDirectory: boolean): Promise => { + const absolute = path.resolve(value); + const root = path.parse(absolute).root; + const parts = absolute.slice(root.length).split(path.sep).filter(Boolean); + if (parts.length === 0) fail(`required regular ${expectsDirectory ? "directory" : "file"} missing: ${label}`); + + let cursor = root; + for (const [index, part] of parts.entries()) { + cursor = path.join(cursor, part); + let entry: Awaited>; + try { + entry = await lstat(cursor); + } catch (error: unknown) { + if (error instanceof Error && (error as NodeJS.ErrnoException).code === "ENOENT") { + fail(`required regular ${expectsDirectory ? "directory" : "file"} missing: ${label}`); + } + throw error; + } + + if (entry.isSymbolicLink()) fail(`forbidden symlink path: ${label}`); + if (index === parts.length - 1) { + if (expectsDirectory ? !entry.isDirectory() : !entry.isFile()) { + fail(`non-${expectsDirectory ? "directory" : "file"} path: ${label}`); + } + continue; + } + if (!entry.isDirectory()) fail(`non-directory path component: ${label}`); + } + return absolute; +}; + +export const assertRegularFile = (value: string, label: string): Promise => assertRegularPath(value, label, false); +export const assertRegularDirectory = (value: string, label: string): Promise => assertRegularPath(value, label, true); + +export const readRegularFile = async (filePath: string, label: string): Promise => { + const regular = await assertRegularFile(filePath, label); + return readFile(regular); +}; + +export const readPackageIdentity = async (manifestPath: string): Promise<{ readonly name: string; readonly version: string; readonly sha256: string; }> => { + const raw = await readRegularFile(manifestPath, manifestPath); + const json = parseJson(raw.toString("utf8"), manifestPath); + return { + name: asString(json.name, `${manifestPath}:name`), + version: asString(json.version, `${manifestPath}:version`), + sha256: sha256(raw) + }; +}; + +export const readPackageName = async (packageRoot: string): Promise => { + const packageJsonPath = path.join(packageRoot, "package.json"); + const raw = await readRegularFile(packageJsonPath, `${packageRoot}/package.json`); + const json = parseJson(raw.toString("utf8"), packageJsonPath); + return asString(json.name, `${packageJsonPath}:name`); +}; + +export const assertInsideRoot = (root: string, target: string, label: string): void => { + const rel = path.relative(root, target); + if (rel === "" || rel.startsWith("..") || path.isAbsolute(rel)) fail(`path escapes root: ${label}`); +}; + +export const ensureNoSymlink = (base: string, value: string, label: string): string => { + const absolute = path.resolve(base, value); + assertInsideRoot(base, absolute, label); + return absolute; +}; + +export const toPortable = (value: string): string => value.split(path.sep).join("/"); diff --git a/src/dynamics/buildReceiptProjectLockAuthority.ts b/src/dynamics/buildReceiptProjectLockAuthority.ts new file mode 100644 index 0000000..7f78279 --- /dev/null +++ b/src/dynamics/buildReceiptProjectLockAuthority.ts @@ -0,0 +1,189 @@ +import { realpath } from "node:fs/promises"; +import path from "node:path"; +import { deepFreeze } from "./buildIdentity.js"; +import { + parseLockAuthorityBytes, + type DynamicsReceiptLockToolchainAuthority, + type LockAuthorityParse +} from "./buildReceiptLockAuthority.js"; +import { + asBoolean, + asObject, + asString, + parseJson, + readPackageIdentity, + readRegularFile, + sha256 +} from "./buildReceiptLockPath.js"; +import { + assertCanonicalSelfLinkEntries, + type DynamicsReceiptSelfLinkEntry +} from "./buildReceiptSelfLinks.js"; + +const fail = (message: string): never => { throw new Error(message); }; +const SELF_LINK_PATH = "node_modules/simfile"; + +export interface ProjectLockAuthorityParse extends LockAuthorityParse { + readonly selfLinkEntries: readonly DynamicsReceiptSelfLinkEntry[]; +} + +const decode = (raw: Uint8Array, filePath: string): Record => + parseJson(new TextDecoder("utf-8", { fatal: true }).decode(raw), filePath); + +const assertSelfLinkTarget = (value: string): void => { + if ( + value.includes("\\") || + value.includes("\0") || + value.includes("?") || + value.includes("#") || + value.includes("://") || + path.isAbsolute(value) + ) { + fail(`invalid Simfile self-link target: ${value}`); + } + for (const character of value) { + const code = character.codePointAt(0); + if (code === undefined || code <= 0x1f || (code >= 0x7f && code <= 0x9f)) { + fail(`invalid Simfile self-link target: ${value}`); + } + } + const segments = value.split("/"); + if (segments.length === 0 || segments.some((segment) => segment === "" || segment === ".")) { + fail(`invalid Simfile self-link target: ${value}`); + } + for (const segment of segments) { + if (/^[A-Za-z][A-Za-z0-9+.-]*:/u.test(segment)) fail(`invalid Simfile self-link target: ${value}`); + } +}; + +const readDeclaredSelfLink = ( + packageJson: Record, + rootEntry: Record, + resolved: string, + packageJsonPath: string, + lockPath: string +): void => { + const manifestDependencies = asObject(packageJson.dependencies, `${packageJsonPath}:dependencies`); + const lockDependencies = asObject(rootEntry.dependencies, `${lockPath}:packages[""].dependencies`); + const expected = `file:${resolved}`; + if (asString(manifestDependencies.simfile, `${packageJsonPath}:dependencies.simfile`) !== expected) { + fail("project manifest Simfile self-link mismatch"); + } + if (asString(lockDependencies.simfile, `${lockPath}:packages[""].dependencies.simfile`) !== expected) { + fail("project lock Simfile self-link mismatch"); + } +}; + +const validateTargetIdentity = async ( + projectRoot: string, + resolved: string, + toolchainAuthority: DynamicsReceiptLockToolchainAuthority +): Promise => { + let roots: readonly [string, string]; + try { + roots = await Promise.all([ + realpath(path.join(projectRoot, SELF_LINK_PATH)), + realpath(path.resolve(projectRoot, resolved)) + ]); + } catch { + return fail("Simfile self-link target missing"); + } + const [installedRoot, sourceRoot] = roots; + if (installedRoot !== toolchainAuthority.absoluteRoot || sourceRoot !== toolchainAuthority.absoluteRoot) { + fail("Simfile self-link target authority mismatch"); + } + + const identity = await readPackageIdentity(path.join(sourceRoot, "package.json")); + if ( + identity.name !== toolchainAuthority.root_package_name || + identity.version !== toolchainAuthority.root_package_version || + identity.sha256 !== toolchainAuthority.root_package_sha256 + ) { + fail("Simfile self-link package identity mismatch"); + } +}; + +const recognizeSelfLink = async ( + projectRoot: string, + packageJson: Record, + lock: Record, + lockRaw: Uint8Array, + toolchainAuthority: DynamicsReceiptLockToolchainAuthority +): Promise<{ sanitizedLock: Record; entries: readonly DynamicsReceiptSelfLinkEntry[] }> => { + const lockPath = path.join(projectRoot, "package-lock.json"); + const packageJsonPath = path.join(projectRoot, "package.json"); + const packages = asObject(lock.packages, `${lockPath}:packages`); + const linkPaths = Object.keys(packages).filter((entryPath) => { + const entry = asObject(packages[entryPath], `${lockPath}:packages[${JSON.stringify(entryPath)}]`); + return asBoolean(entry.link, `${lockPath}:packages[${JSON.stringify(entryPath)}].link`) === true; + }); + const sourcePaths = Object.keys(packages).filter((entryPath) => + entryPath !== "" && !entryPath.startsWith("node_modules/") + ); + + if (linkPaths.length === 0 && sourcePaths.length === 0) { + return { sanitizedLock: lock, entries: deepFreeze([]) }; + } + if (linkPaths.length !== 1 || linkPaths[0] !== SELF_LINK_PATH) fail("invalid Simfile self-link entry"); + + const linkEntry = asObject(packages[SELF_LINK_PATH], `${lockPath}:packages[${JSON.stringify(SELF_LINK_PATH)}]`); + const resolved = asString(linkEntry.resolved, `${lockPath}:packages[${JSON.stringify(SELF_LINK_PATH)}].resolved`); + assertSelfLinkTarget(resolved); + if (sourcePaths.length !== 1 || sourcePaths[0] !== resolved) fail("unpaired Simfile self-link source"); + + const sourceEntry = asObject(packages[resolved], `${lockPath}:packages[${JSON.stringify(resolved)}]`); + if (asBoolean(sourceEntry.link, `${lockPath}:packages[${JSON.stringify(resolved)}].link`) === true) { + fail("Simfile self-link source must not be a link"); + } + const sourceName = sourceEntry.name === undefined + ? null + : asString(sourceEntry.name, `${lockPath}:packages[${JSON.stringify(resolved)}].name`); + if (sourceName !== null && sourceName !== "simfile") fail("Simfile self-link source name mismatch"); + const sourceVersion = asString(sourceEntry.version, `${lockPath}:packages[${JSON.stringify(resolved)}].version`); + if (sourceVersion !== toolchainAuthority.root_package_version) fail("Simfile self-link source version mismatch"); + + const rootEntry = asObject(packages[""], `${lockPath}:packages[""]`); + readDeclaredSelfLink(packageJson, rootEntry, resolved, packageJsonPath, lockPath); + await validateTargetIdentity(projectRoot, resolved, toolchainAuthority); + + const sanitizedPackages = { ...packages }; + delete sanitizedPackages[resolved]; + delete sanitizedPackages[SELF_LINK_PATH]; + const entry: DynamicsReceiptSelfLinkEntry = deepFreeze({ + manager: "npm", + lockfile_version: 3, + lock_sha256: sha256(lockRaw), + lock_entry_path: SELF_LINK_PATH, + package_name: "simfile", + package_version: toolchainAuthority.root_package_version, + package_manifest_sha256: toolchainAuthority.root_package_sha256, + target: "toolchain_authority_root" + }); + return { + sanitizedLock: { ...lock, packages: sanitizedPackages }, + entries: assertCanonicalSelfLinkEntries([entry]) + }; +}; + +export const readProjectLockAuthorityParse = async ( + projectRoot: string, + toolchainAuthority: DynamicsReceiptLockToolchainAuthority +): Promise => { + const packageJsonPath = path.join(projectRoot, "package.json"); + const lockPath = path.join(projectRoot, "package-lock.json"); + const packageJsonRaw = await readRegularFile(packageJsonPath, packageJsonPath); + const lockRaw = await readRegularFile(lockPath, lockPath); + const packageJson = decode(packageJsonRaw, packageJsonPath); + const lock = decode(lockRaw, lockPath); + const recognized = await recognizeSelfLink(projectRoot, packageJson, lock, lockRaw, toolchainAuthority); + const parsed = parseLockAuthorityBytes( + projectRoot, + packageJsonRaw, + new TextEncoder().encode(JSON.stringify(recognized.sanitizedLock)) + ); + return deepFreeze({ + ...parsed, + lockDigest: sha256(lockRaw), + selfLinkEntries: recognized.entries + }); +}; diff --git a/src/dynamics/buildReceiptSelfLinks.ts b/src/dynamics/buildReceiptSelfLinks.ts new file mode 100644 index 0000000..5787b79 --- /dev/null +++ b/src/dynamics/buildReceiptSelfLinks.ts @@ -0,0 +1,79 @@ +import { canonicalJson, compareUtf16, deepFreeze } from "./buildIdentity.js"; + +const fail = (message: string): never => { throw new Error(message); }; + +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const VERSION_PATTERN = /^[0-9A-Za-z][0-9A-Za-z.+_-]*$/u; +const EXPECTED_KEYS = [ + "manager", + "lockfile_version", + "lock_sha256", + "lock_entry_path", + "package_name", + "package_version", + "package_manifest_sha256", + "target" +] as const; + +export interface DynamicsReceiptSelfLinkEntry { + readonly manager: "npm"; + readonly lockfile_version: 3; + readonly lock_sha256: string; + readonly lock_entry_path: "node_modules/simfile"; + readonly package_name: "simfile"; + readonly package_version: string; + readonly package_manifest_sha256: string; + readonly target: "toolchain_authority_root"; +} + +const assertRecord = (value: unknown, label: string): Readonly> => { + if (value === null || typeof value !== "object" || Array.isArray(value)) fail(`${label}: expected object`); + return value as Readonly>; +}; + +const assertExactKeys = (value: unknown, label: string): void => { + const actual = Object.keys(assertRecord(value, label)).sort(compareUtf16); + const expected = [...EXPECTED_KEYS].sort(compareUtf16); + if (actual.length !== expected.length) fail(`${label}: unexpected key count`); + for (let index = 0; index < expected.length; index += 1) { + if (actual[index] !== expected[index]) fail(`${label}: unexpected field ${actual[index]}`); + } +}; + +export const compareDynamicsReceiptSelfLinkEntries = ( + left: DynamicsReceiptSelfLinkEntry, + right: DynamicsReceiptSelfLinkEntry +): number => compareUtf16(canonicalJson(left), canonicalJson(right)); + +const assertSelfLinkEntry = (entry: DynamicsReceiptSelfLinkEntry, label: string): void => { + assertExactKeys(entry, label); + if (entry.manager !== "npm") fail(`${label}.manager: expected npm`); + if (entry.lockfile_version !== 3) fail(`${label}.lockfile_version: expected 3`); + if (!SHA256_PATTERN.test(entry.lock_sha256)) fail(`${label}.lock_sha256: expected lowercase sha-256`); + if (entry.lock_entry_path !== "node_modules/simfile") fail(`${label}.lock_entry_path: expected node_modules/simfile`); + if (entry.package_name !== "simfile") fail(`${label}.package_name: expected simfile`); + if (!VERSION_PATTERN.test(entry.package_version)) fail(`${label}.package_version: invalid package version`); + if (!SHA256_PATTERN.test(entry.package_manifest_sha256)) { + fail(`${label}.package_manifest_sha256: expected lowercase sha-256`); + } + if (entry.target !== "toolchain_authority_root") fail(`${label}.target: expected toolchain_authority_root`); +}; + +export const assertCanonicalSelfLinkEntries = ( + entries: readonly DynamicsReceiptSelfLinkEntry[] +): readonly DynamicsReceiptSelfLinkEntry[] => { + if (entries.length > 1) fail("self-link entries: expected at most one entry"); + const sorted = [...entries].sort(compareDynamicsReceiptSelfLinkEntries); + if (canonicalJson(sorted) !== canonicalJson(entries)) fail("self-link entries are not canonical"); + + const seen = new Set(); + for (let index = 0; index < entries.length; index += 1) { + const entry = entries[index]; + if (entry === undefined) fail(`self-link entries[${index}]: expected entry`); + assertSelfLinkEntry(entry, `self-link entries[${index}]`); + const key = canonicalJson(entry); + if (seen.has(key)) fail("self-link entries: duplicate entry"); + seen.add(key); + } + return deepFreeze(sorted); +}; diff --git a/src/dynamics/buildReceiptSourceEvidence.ts b/src/dynamics/buildReceiptSourceEvidence.ts new file mode 100644 index 0000000..d07225b --- /dev/null +++ b/src/dynamics/buildReceiptSourceEvidence.ts @@ -0,0 +1,357 @@ +import { canonicalJson, compareUtf16, deepFreeze, type DynamicsBuildInputDescriptor } from "./buildIdentity.js"; +import { + compareDynamicsReceiptLockPortableRecords, + type DynamicsReceiptLockDeduplicatedLock, + type DynamicsReceiptLockPortableRecord, + type DynamicsReceiptLockToolIdentity +} from "./buildReceiptLock.js"; +import { assertPortablePath } from "./buildReceiptLockPath.js"; +import { DYNAMICS_BUILD_PREPARATION_POLICY } from "./buildInput.js"; + +const fail = (message: string): never => { throw new Error(message); }; + +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const PACKAGE_NAME_PATTERN = new RegExp(DYNAMICS_BUILD_PREPARATION_POLICY.package.namePattern, "u"); +const PACKAGE_VERSION_PATTERN = new RegExp(DYNAMICS_BUILD_PREPARATION_POLICY.package.versionPattern, "u"); +const CONTROL_TEXT_PATTERN = /[\u0000-\u001f\u007f-\u009f]/u; +const QUERY_FRAGMENT_PATTERN = /[?#\\]/u; +const QUERY_INJECTION_PATTERN = /(?:^|[/?#@:\\\\])(?:secret|token|credential)(?=$|[/?#@:\\\\])/iu; +const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//iu; +const CREDENTIAL_PATTERN = /[^\\s:]+:[^@\\s]+@/u; + +const assertString = (value: unknown, label: string): string => { + if (typeof value !== "string") fail(`${label}: expected string`); + const text = value as string; + if (text.length === 0) fail(`${label}: expected non-empty string`); + return text; +}; + +const assertPresent = (value: T | undefined, label: string): T => { + if (value === undefined) fail(label); + return value!; +}; + +const assertRecord = (value: unknown, label: string): Readonly> => { + if (!value || typeof value !== "object" || Array.isArray(value)) fail(`${label}: expected object`); + return value as Readonly>; +}; + +const assertNpmPackageName = (value: unknown, label: string): string => { + const text = assertString(value, label); + if (!PACKAGE_NAME_PATTERN.test(text)) fail(`${label}: invalid npm package name`); + if (CONTROL_TEXT_PATTERN.test(text)) fail(`${label}: contains control character`); + if (QUERY_FRAGMENT_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (SCHEME_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (CREDENTIAL_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (QUERY_INJECTION_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + return text; +}; + +const assertNpmPackageVersion = (value: unknown, label: string): string => { + const text = assertString(value, label); + if (!PACKAGE_VERSION_PATTERN.test(text)) fail(`${label}: invalid npm package version`); + if (CONTROL_TEXT_PATTERN.test(text)) fail(`${label}: contains control character`); + if (QUERY_FRAGMENT_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (SCHEME_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (CREDENTIAL_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (QUERY_INJECTION_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + return text; +}; + +const assertExactKeys = (value: unknown, label: string, expected: readonly string[]): void => { + const record = assertRecord(value, label); + const actual = Object.keys(record).sort(compareUtf16); + const next = [...expected].sort(compareUtf16); + if (actual.length !== next.length) fail(`${label}: unexpected key count`); + for (let index = 0; index < next.length; index += 1) { + if (actual[index] !== next[index]) fail(`${label}: unexpected field ${actual[index]}`); + } +}; + +const assertSha256 = (value: unknown, label: string): string => { + const text = assertString(value, label); + if (!SHA256_PATTERN.test(text)) fail(`${label}: expected lowercase sha-256`); + return text; +}; + +const compareToolIdentities = ( + left: Readonly<{ readonly name: string; readonly version: string; readonly manifest_sha256: string; readonly lock_entry_path: string; readonly lock_sha256: string }>, + right: Readonly<{ readonly name: string; readonly version: string; readonly manifest_sha256: string; readonly lock_entry_path: string; readonly lock_sha256: string }> +): number => { + const compareName = compareUtf16(left.name, right.name); + if (compareName !== 0) return compareName; + const compareVersion = compareUtf16(left.version, right.version); + if (compareVersion !== 0) return compareVersion; + const compareManifest = compareUtf16(left.manifest_sha256, right.manifest_sha256); + if (compareManifest !== 0) return compareManifest; + const compareEntry = compareUtf16(left.lock_entry_path, right.lock_entry_path); + if (compareEntry !== 0) return compareEntry; + return compareUtf16(left.lock_sha256, right.lock_sha256); +}; + +const assertToolIdentityShape = (tool: DynamicsReceiptLockToolIdentity, label: string): void => { + assertExactKeys(tool, label, ["name", "version", "manifest_sha256", "lock_entry_path", "lock_sha256"]); + if (tool.name !== "esbuild" && tool.name !== "typescript") fail(`${label}.name: expected esbuild|typescript`); + assertNpmPackageVersion(tool.version, `${label}.version`); + assertSha256(tool.manifest_sha256, `${label}.manifest_sha256`); + assertPortablePath(tool.lock_entry_path, `${label}.lock_entry_path`); + assertSha256(tool.lock_sha256, `${label}.lock_sha256`); +}; + +const assertSortedUniqueToolIdentities = (tools: readonly DynamicsReceiptLockToolIdentity[], label: string): void => { + const seen = new Set(); + for (let index = 0; index < tools.length; index += 1) { + const identity = tools[index]; + if (identity === undefined) fail(`${label}[${index}]: expected tool identity`); + assertToolIdentityShape(identity, `${label}[${index}]`); + + if (index > 0) { + const previous = tools[index - 1]; + if (previous === undefined) continue; + if (compareToolIdentities(previous, identity) >= 0) fail(`${label}: not total-order`); + } + + const key = `${identity.name}\u0000${identity.version}\u0000${identity.manifest_sha256}\u0000${identity.lock_entry_path}\u0000${identity.lock_sha256}`; + if (seen.has(key)) fail(`${label}: duplicate tool identity`); + seen.add(key); + } +}; + +const isPackageClaim = (input: DynamicsReceiptLockPortableRecord): boolean => input.tool_identities.length === 0; +const isTypeOnlyClaim = (input: DynamicsReceiptLockPortableRecord): boolean => input.tool_identities.length === 2; + +const sourceGraphClaimKey = (input: DynamicsBuildInputDescriptor): string => { + if (input.kind === "project") return `project\u0000${input.path}\u0000${input.sha256}`; + if (input.kind === "package") return `package\u0000${input.package_name}\u0000${input.package_version}\u0000${input.package_path}\u0000${input.sha256}\u0000${input.manifest_sha256}`; + return `type-only\u0000${input.package_name}\u0000${input.package_version}\u0000${input.manifest_sha256}\u0000${input.files + .map((file) => `${file.path}\u0000${file.sha256}`).join("\u0001")}`; +}; + +const portableClaimKey = (claim: DynamicsReceiptLockPortableRecord): string => { + const source = claim.source_digests[0]; + if (source === undefined) fail("portable claim missing source digest"); + return isPackageClaim(claim) + ? `package\u0000${claim.package_name}\u0000${claim.package_version}\u0000${source.path}\u0000${source.sha256}\u0000${claim.package_manifest_sha256}` + : `type-only\u0000${claim.package_name}\u0000${claim.package_version}\u0000${claim.package_manifest_sha256}\u0000${claim.source_digests + .map((entry) => `${entry.path}\u0000${entry.sha256}`).join("\u0001")}`; +}; + +const assertSourceDigests = (claim: DynamicsReceiptLockPortableRecord, label: string): void => { + if (claim.source_digests.length === 0) fail(`${label}.source_digests is empty`); + const seen = new Set(); + for (let index = 0; index < claim.source_digests.length; index += 1) { + const source = claim.source_digests[index]; + if (source === undefined) fail(`${label}.source_digests[${index}]: expected source`); + const canonical = `${source.path}\u0000${source.sha256}`; + if (seen.has(canonical)) fail(`${label}.source_digests: duplicate source`); + seen.add(canonical); + if (index > 0) { + const previous = claim.source_digests[index - 1]; + if (previous === undefined) continue; + if (compareUtf16(previous.path, source.path) >= 0) fail(`${label}.source_digests: non-canonical order`); + } + + if (!source.path.startsWith("./")) fail(`${label}.source_digests[${index}].path: expected portable relative path`); + assertPortablePath(source.path.slice(2), `${label}.source_digests[${index}].path`); + assertSha256(source.sha256, `${label}.source_digests[${index}].sha256`); + } +}; + +export const assertCanonicalClaims = (claims: readonly DynamicsReceiptLockPortableRecord[]): readonly DynamicsReceiptLockPortableRecord[] => { + const sorted = [...claims].sort(compareDynamicsReceiptLockPortableRecords); + if (compareUtf16(canonicalJson(sorted), canonicalJson(claims)) !== 0) fail("portable claims are not canonical"); + return deepFreeze(sorted); +}; + +export const assertPortableClaims = (claims: readonly DynamicsReceiptLockPortableRecord[]): void => { + for (const claim of claims) { + assertExactKeys(claim, "portable claim", [ + "manager", + "lockfile_version", + "root_package_name", + "root_package_version", + "root_package_sha256", + "lock_sha256", + "lock_entry_path", + "package_name", + "package_version", + "package_manifest_sha256", + "source_digests", + "tool_identities" + ]); + + if (claim.manager !== "npm") fail("portable claim manager must be npm"); + if (claim.lockfile_version !== 3) fail("portable claim lockfile version must be 3"); + assertNpmPackageName(claim.root_package_name, "portable claim root_package_name"); + assertNpmPackageVersion(claim.root_package_version, "portable claim root_package_version"); + assertSha256(claim.root_package_sha256, "portable claim root_package_sha256"); + assertSha256(claim.lock_sha256, "portable claim lock_sha256"); + assertSha256(claim.package_manifest_sha256, "portable claim package_manifest_sha256"); + assertNpmPackageName(claim.package_name, "portable claim package_name"); + assertNpmPackageVersion(claim.package_version, "portable claim package_version"); + assertSourceDigests(claim, "portable claim"); + + if (isPackageClaim(claim)) { + if (claim.lock_entry_path === "") fail("portable package claim expected lock_entry_path"); + assertPortablePath(claim.lock_entry_path, "portable claim lock_entry_path"); + if (claim.source_digests.length !== 1) fail("portable package claim must have one source digest"); + continue; + } + + if (!isTypeOnlyClaim(claim)) fail("portable claim has unsupported tool-identity arity"); + + if (claim.lock_entry_path !== "") fail("portable type-only claim must have empty lock_entry_path"); + assertSortedUniqueToolIdentities(claim.tool_identities, "portable claim tool_identities"); + } +}; + +export const assertDedupedLocks = (value: readonly DynamicsReceiptLockDeduplicatedLock[]): readonly DynamicsReceiptLockDeduplicatedLock[] => { + const seenBySha = new Set(); + for (let index = 1; index < value.length; index += 1) { + const previous = value[index - 1]; + const current = value[index]; + if (previous === undefined || current === undefined) continue; + if (compareUtf16(previous.lock_sha256, current.lock_sha256) >= 0) { + fail("deduped locks are not ordered by lock_sha256"); + } + } + + const seen = new Set(); + for (const lock of value) { + if (lock.manager !== "npm") fail("deduped lock manager must be npm"); + if (lock.lockfile_version !== 3) fail("deduped lock fileversion must be 3"); + assertNpmPackageName(lock.root_package_name, "deduped lock root_package_name"); + assertNpmPackageVersion(lock.root_package_version, "deduped lock root_package_version"); + assertSha256(lock.root_package_sha256, "deduped lock root_package_sha256"); + assertSha256(lock.lock_sha256, "deduped lock lock_sha256"); + + const key = `${lock.root_package_name}\u0000${lock.root_package_version}\u0000${lock.root_package_sha256}\u0000${lock.lock_sha256}`; + if (seen.has(key)) fail(`duplicate deduped lock: ${key}`); + seen.add(key); + + if (seenBySha.has(lock.lock_sha256)) fail(`ambiguous deduped lock sha256: ${lock.lock_sha256}`); + seenBySha.add(lock.lock_sha256); + } + + return deepFreeze([...value]); +}; + +export const assertSourceClaims = ( + sourceGraph: readonly DynamicsBuildInputDescriptor[], + portableClaims: readonly DynamicsReceiptLockPortableRecord[], + lockInputs: readonly (DynamicsBuildInputDescriptor & { kind: "package" | "type-only" })[], + buildTools: readonly DynamicsReceiptLockToolIdentity[] +): void => { + const packageClaims = new Map(); + const typeOnlyClaims = new Map(); + for (const claim of portableClaims) { + if (isPackageClaim(claim)) { + const key = portableClaimKey(claim); + if (packageClaims.has(key)) fail(`duplicate package portable claim: ${key}`); + packageClaims.set(key, claim); + continue; + } + if (isTypeOnlyClaim(claim)) { + const key = portableClaimKey(claim); + if (typeOnlyClaims.has(key)) fail(`duplicate type-only portable claim: ${key}`); + typeOnlyClaims.set(key, claim); + continue; + } + fail("portable claim has unsupported tool-identity arity"); + } + + let packageCount = 0; + let typeOnlyCount = 0; + for (const input of lockInputs) { + if (input.kind === "package") packageCount += 1; + if (input.kind === "type-only") typeOnlyCount += 1; + + const key = sourceGraphClaimKey(input); + if (input.kind === "package") { + const claim = assertPresent(packageClaims.get(key), `missing claim for prepared descriptor: ${key}`); + + packageClaims.delete(key); + if (claim.source_digests.length !== 1) fail(`package claim should have one source: ${key}`); + const source = claim.source_digests[0]; + if (source === undefined || source.path !== `./${input.package_path.slice(2)}` || source.sha256 !== input.sha256) { + fail(`package claim mismatch: ${key}`); + } + if (claim.package_name !== input.package_name || claim.package_version !== input.package_version) { + fail(`package claim identity mismatch: ${key}`); + } + if (claim.package_manifest_sha256 !== input.manifest_sha256) { + fail(`package manifest mismatch: ${key}`); + } + if (claim.tool_identities.length !== 0) fail(`package claim should not include issuer tool identities: ${key}`); + continue; + } + + const claim = assertPresent(typeOnlyClaims.get(key), `missing claim for prepared descriptor: ${key}`); + + typeOnlyClaims.delete(key); + + if (claim.tool_identities.length !== 2) { + fail(`type-only claim should include two issuer tool identities: ${key}`); + } + if (canonicalJson(claim.tool_identities) !== canonicalJson(buildTools)) { + fail(`type-only claim tool identities mismatch: ${key}`); + } + if (claim.source_digests.length !== input.files.length) { + fail(`type-only claim source count mismatch: ${key}`); + } + for (let index = 0; index < input.files.length; index += 1) { + const expected = input.files[index]; + const observed = claim.source_digests[index]; + if (expected === undefined || observed === undefined || observed.path !== expected.path || observed.sha256 !== expected.sha256) { + fail(`type-only claim source mismatch: ${key}`); + } + } + if ( + claim.package_name !== input.package_name + || claim.package_version !== input.package_version + || claim.package_manifest_sha256 !== input.manifest_sha256 + ) { + fail(`type-only claim identity mismatch: ${key}`); + } + } + + if (packageClaims.size !== 0) fail(`uncovered package claim: ${[...packageClaims.keys()].join(",")}`); + if (typeOnlyClaims.size !== 0) fail(`uncovered type-only claim: ${[...typeOnlyClaims.keys()].join(",")}`); + + if (sourceGraph.filter((entry) => entry.kind === "package").length !== packageCount) { + fail("package descriptor count mismatch"); + } + if (sourceGraph.filter((entry) => entry.kind === "type-only").length !== typeOnlyCount) { + fail("type-only descriptor count mismatch"); + } +} + +export const assertToolset = ( + buildTools: readonly DynamicsReceiptLockToolIdentity[], + normalizedEsbuild: string, + normalizedTypescript: string +): readonly DynamicsReceiptLockToolIdentity[] => { + if (buildTools.length !== 2) fail("expected exactly two tool identities"); + assertSortedUniqueToolIdentities(buildTools, "prepared.buildTools"); + + let esbuild: DynamicsReceiptLockToolIdentity | undefined; + let typescript: DynamicsReceiptLockToolIdentity | undefined; + for (const tool of buildTools) { + if (tool.name === "esbuild") esbuild = tool; + else if (tool.name === "typescript") typescript = tool; + } + + const esbuildTool = assertPresent(esbuild, "expected tool identity for esbuild"); + const typescriptTool = assertPresent(typescript, "expected tool identity for typescript"); + if (esbuildTool.version !== normalizedEsbuild) fail("esbuild version mismatch between closure and toolchain"); + if (typescriptTool.version !== normalizedTypescript) fail("typescript version mismatch between closure and toolchain"); + + const seen = new Set(); + for (const tool of buildTools) { + const key = `${tool.name}\u0000${tool.version}\u0000${tool.manifest_sha256}\u0000${tool.lock_entry_path}\u0000${tool.lock_sha256}`; + if (seen.has(key)) fail("tool identity duplicate"); + seen.add(key); + } + + return deepFreeze([...buildTools]); +}; diff --git a/src/dynamics/buildReceiptValidation.ts b/src/dynamics/buildReceiptValidation.ts new file mode 100644 index 0000000..3b5b034 --- /dev/null +++ b/src/dynamics/buildReceiptValidation.ts @@ -0,0 +1,376 @@ +import { + canonicalJson, + compareUtf16, + deepFreeze, + sha256, + type DynamicsBuildInputDescriptor +} from "./buildIdentity.js"; +import { DYNAMICS_BUILD_CONTRACT, DYNAMICS_BUILD_PREPARATION_POLICY } from "./buildInput.js"; +import { DYNAMICS_STATIC_CLOSURE_POLICY } from "./buildStaticPolicy.js"; +import { assertPortablePath } from "./buildReceiptLockPath.js"; +import { type PreparedDynamicsBuild } from "./build.js"; + +const fail = (message: string): never => { throw new Error(message); }; + +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const NODE_BUILTINS = new Set(DYNAMICS_BUILD_PREPARATION_POLICY.nodeBuiltins); +const PACKAGE_NAME_PATTERN = new RegExp(DYNAMICS_BUILD_PREPARATION_POLICY.package.namePattern, "u"); +const PACKAGE_VERSION_PATTERN = new RegExp(DYNAMICS_BUILD_PREPARATION_POLICY.package.versionPattern, "u"); +const CONTROL_TEXT_PATTERN = /[\u0000-\u001f\u007f-\u009f]/u; +const QUERY_FRAGMENT_PATTERN = /[?#\\]/u; +const QUERY_INJECTION_PATTERN = /(?:^|[/?#@:\\\\])(?:secret|token|credential)(?=$|[/?#@:\\\\])/iu; +const SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:\/\//iu; +const CREDENTIAL_PATTERN = /[^\\s:]+:[^@\\s]+@/u; + +export interface NormalizedPreparedBuild { + readonly artifactBytes: readonly number[]; + readonly artifactSha256: string; + readonly closureSha256: string; + readonly inputs: readonly DynamicsBuildInputDescriptor[]; + readonly nodeBuiltins: readonly string[]; + readonly closure: { + readonly entry: string; + readonly esbuildVersion: string; + readonly inputs: readonly DynamicsBuildInputDescriptor[]; + readonly preparedTypecheckMode: "none" | "typescript"; + readonly typescriptVersion: string; + readonly usedNodeBuiltins: readonly string[]; + }; +} + +export const closurePreparationPolicy = deepFreeze({ + ...DYNAMICS_BUILD_PREPARATION_POLICY, + staticClosure: DYNAMICS_STATIC_CLOSURE_POLICY +}); + +export const buildReceiptConfigDigest = sha256(canonicalJson({ + buildContract: DYNAMICS_BUILD_CONTRACT, + preparationPolicy: closurePreparationPolicy +})); + +const assertString = (value: unknown, label: string): string => { + if (typeof value !== "string") fail(`${label}: expected string`); + const text = value as string; + if (text.length === 0) fail(`${label}: expected non-empty string`); + return text; +}; + +const assertNpmPackageName = (value: unknown, label: string): string => { + const text = assertString(value, label); + if (!PACKAGE_NAME_PATTERN.test(text)) fail(`${label}: invalid npm package name`); + if (CONTROL_TEXT_PATTERN.test(text)) fail(`${label}: contains control character`); + if (QUERY_FRAGMENT_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (SCHEME_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (CREDENTIAL_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (QUERY_INJECTION_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + return text; +}; + +const assertNpmPackageVersion = (value: unknown, label: string): string => { + const text = assertString(value, label); + if (!PACKAGE_VERSION_PATTERN.test(text)) fail(`${label}: invalid npm package version`); + if (CONTROL_TEXT_PATTERN.test(text)) fail(`${label}: contains control character`); + if (QUERY_FRAGMENT_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (SCHEME_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (CREDENTIAL_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + if (QUERY_INJECTION_PATTERN.test(text)) fail(`${label}: invalid npm package metadata`); + return text; +}; + +const assertRecord = (value: unknown, label: string): Readonly> => { + if (!value || typeof value !== "object" || Array.isArray(value)) fail(`${label}: expected object`); + return value as Readonly>; +}; + +const asArray = (value: unknown, label: string): readonly T[] => { + if (!Array.isArray(value)) fail(`${label}: expected array`); + return value as readonly T[]; +}; + +const assertSha256 = (value: unknown, label: string): string => { + const text = assertString(value, label); + if (!SHA256_PATTERN.test(text)) fail(`${label}: expected lowercase sha-256`); + return text; +}; + +const assertPortableRelative = (value: unknown, label: string): string => { + const candidate = assertString(value, label); + if (!candidate.startsWith("./")) fail(`${label}: expected portable relative path`); + assertPortablePath(candidate.slice(2), `${label}.portable`); + return candidate; +}; + +const assertExactKeys = (value: unknown, label: string, expected: readonly string[]): void => { + const record = assertRecord(value, label); + const actual = Object.keys(record).sort(compareUtf16); + const next = [...expected].sort(compareUtf16); + if (actual.length !== next.length) fail(`${label}: unexpected key count`); + for (let index = 0; index < next.length; index += 1) { + if (actual[index] !== next[index]) fail(`${label}: unexpected field ${actual[index]}`); + } +}; + +const sortCanonical = (value: readonly T[]): T[] => + [...value].sort((left, right) => compareUtf16(canonicalJson(left), canonicalJson(right))); + +const assertModes = (value: unknown, label: string): readonly ("runtime" | "type-only")[] => { + const raw = asArray(value, label); + const normalized: ("runtime" | "type-only")[] = []; + const seen = new Set(); + for (let index = 0; index < raw.length; index += 1) { + const mode = assertString(raw[index], `${label}[${index}]`); + if (mode === "runtime") { + if (seen.has(mode)) fail(`${label}: duplicate mode`); + seen.add(mode); + normalized.push("runtime"); + } else if (mode === "type-only") { + if (seen.has(mode)) fail(`${label}: duplicate mode`); + seen.add(mode); + normalized.push("type-only"); + } else { + fail(`${label}[${index}]: expected runtime/type-only`); + } + } + if (normalized.length === 0) fail(`${label}: expected non-empty mode list`); + + const canonical = [...normalized].sort(compareUtf16); + if (compareUtf16(canonicalJson(canonical), canonicalJson(normalized)) !== 0) { + fail(`${label}: non-canonical mode order`); + } + + return deepFreeze(normalized); +}; + +const normalizeTypeOnlyFiles = (value: unknown, label: string): readonly { readonly path: string; readonly sha256: string }[] => { + const raw = asArray(value, label); + if (raw.length === 0) fail(`${label}: expected non-empty files`); + + const files = raw.map((entry, index) => { + const record = assertRecord(entry, `${label}[${index}]`); + assertExactKeys(record, `${label}[${index}]`, ["path", "sha256"]); + return { + path: assertPortableRelative(record.path, `${label}[${index}].path`), + sha256: assertSha256(record.sha256, `${label}[${index}].sha256`) + }; + }); + + const seenPaths = new Set(); + for (const file of files) { + if (seenPaths.has(file.path)) fail(`${label}: duplicate file path`); + seenPaths.add(file.path); + } + + const canonical = [...files].sort((left, right) => compareUtf16(left.path, right.path) || compareUtf16(left.sha256, right.sha256)); + if (compareUtf16(canonicalJson(canonical), canonicalJson(files)) !== 0) { + fail(`${label}: non-canonical file order`); + } + + return deepFreeze(files); +}; + +const normalizePreparedDescriptor = (value: unknown, index: number): DynamicsBuildInputDescriptor => { + const record = assertRecord(value, `prepared.inputs[${index}]`); + const kind = assertString(record.kind, `prepared.inputs[${index}].kind`); + switch (kind) { + case "project": { + assertExactKeys(record, `prepared.inputs[${index}]`, ["kind", "modes", "path", "sha256"]); + return { + kind: "project", + modes: assertModes(record.modes, `prepared.inputs[${index}].modes`), + path: assertPortableRelative(record.path, `prepared.inputs[${index}].path`), + sha256: assertSha256(record.sha256, `prepared.inputs[${index}].sha256`) + }; + } + case "package": { + assertExactKeys(record, `prepared.inputs[${index}]`, [ + "kind", + "manifest_sha256", + "modes", + "package_name", + "package_path", + "package_version", + "sha256" + ]); + return { + kind: "package", + manifest_sha256: assertSha256(record.manifest_sha256, `prepared.inputs[${index}].manifest_sha256`), + modes: assertModes(record.modes, `prepared.inputs[${index}].modes`), + package_name: assertNpmPackageName(record.package_name, `prepared.inputs[${index}].package_name`), + package_path: assertPortableRelative(record.package_path, `prepared.inputs[${index}].package_path`), + package_version: assertNpmPackageVersion(record.package_version, `prepared.inputs[${index}].package_version`), + sha256: assertSha256(record.sha256, `prepared.inputs[${index}].sha256`) + }; + } + case "type-only": { + assertExactKeys(record, `prepared.inputs[${index}]`, ["kind", "files", "manifest_sha256", "package_name", "package_version", "surface"]); + if (assertNpmPackageName(record.package_name, `prepared.inputs[${index}].package_name`) !== "simfile") { + fail(`prepared.inputs[${index}].package_name: invalid type-only package`); + } + if (assertString(record.surface, `prepared.inputs[${index}].surface`) !== "dynamics") { + fail(`prepared.inputs[${index}].surface: expected dynamics`); + } + + return { + kind: "type-only", + files: normalizeTypeOnlyFiles(record.files, `prepared.inputs[${index}].files`), + manifest_sha256: assertSha256(record.manifest_sha256, `prepared.inputs[${index}].manifest_sha256`), + package_name: "simfile", + package_version: assertNpmPackageVersion(record.package_version, `prepared.inputs[${index}].package_version`), + surface: "dynamics" + }; + } + default: + fail(`prepared.inputs[${index}].kind: unsupported kind`); + } + return fail(`prepared.inputs[${index}].kind: unsupported kind`); +}; + +export const normalizePreparedInputs = (value: unknown): readonly DynamicsBuildInputDescriptor[] => { + const raw = asArray(value, "prepared.inputs"); + const normalized = raw.map((entry, index) => normalizePreparedDescriptor(entry, index)); + + const sorted = sortCanonical(normalized); + if (compareUtf16(canonicalJson(sorted), canonicalJson(normalized)) !== 0) { + fail("prepared.inputs are not canonical sorted"); + } + + const seen = new Set(); + const projectFiles = new Map(); + const packageFiles = new Map(); + for (const descriptor of normalized) { + const key = canonicalJson(descriptor); + if (seen.has(key)) fail(`prepared.inputs contains duplicate descriptor: ${key}`); + seen.add(key); + + if (descriptor.kind === "project") { + const previous = projectFiles.get(descriptor.path); + if (previous !== undefined) { + if (previous === descriptor.sha256) fail(`prepared.inputs contains duplicate descriptor: ${key}`); + fail(`prepared.inputs contains same-path conflicting descriptor: ${descriptor.path}`); + } + projectFiles.set(descriptor.path, descriptor.sha256); + continue; + } + + if (descriptor.kind === "package") { + const key = `${descriptor.package_name}\u0000${descriptor.package_version}\u0000${descriptor.package_path}`; + const previous = packageFiles.get(key); + if (previous !== undefined) { + if (previous === descriptor.sha256) { + fail(`prepared.inputs contains duplicate descriptor: ${canonicalJson(descriptor)}`); + } + fail(`prepared.inputs contains same-path conflicting descriptor: ${descriptor.package_path}`); + } + packageFiles.set(key, descriptor.sha256); + continue; + } + + if (descriptor.kind !== "type-only") { + fail(`prepared.inputs[${normalized.indexOf(descriptor)}].kind: unsupported kind`); + } + } + + return deepFreeze(normalized); +}; + +const normalizeNodeBuiltins = (value: unknown, label: string): readonly string[] => { + const raw = asArray(value, label); + const normalized: string[] = []; + const seen = new Set(); + for (let index = 0; index < raw.length; index += 1) { + const builtin = assertString(raw[index], `${label}[${index}]`); + if (!builtin.startsWith("node:")) fail(`${label}[${index}]: expected node builtin`); + if (!NODE_BUILTINS.has(builtin)) fail(`${label}[${index}]: unexpected node builtin`); + if (seen.has(builtin)) fail(`${label}: duplicate builtin`); + seen.add(builtin); + normalized.push(builtin); + } + + const canonical = [...normalized].sort(compareUtf16); + if (compareUtf16(canonicalJson(canonical), canonicalJson(normalized)) !== 0) { + fail(`${label}: non-canonical order`); + } + + return deepFreeze(normalized); +}; + +const normalizeType = (value: unknown, label: string): "none" | "typescript" => { + const type = assertString(value, label); + if (type === "none") return "none"; + if (type === "typescript") return "typescript"; + fail(`${label}: expected typecheck mode`); + throw new Error("unreachable"); +}; + +const assertType = (value: unknown, label: string): string => { + return assertString(value, label); +}; + +export const normalizePreparedBuild = (prepared: PreparedDynamicsBuild): NormalizedPreparedBuild => { + if (typeof prepared.module !== "string" || prepared.module.length === 0) fail("prepared.module: expected non-empty string"); + + const artifactBytes = asArray(prepared.artifactBytes, "prepared.artifactBytes").map((entry, index) => { + if (!Number.isInteger(entry) || entry < 0 || entry > 255) fail(`prepared.artifactBytes[${index}]: expected byte`); + return entry; + }); + const artifactSha256 = sha256(Uint8Array.from(artifactBytes)); + const declaredArtifactSha256 = assertSha256(prepared.artifactSha256, "prepared.artifactSha256"); + if (artifactSha256 !== declaredArtifactSha256) fail("prepared artifact SHA mismatch"); + + const inputs = normalizePreparedInputs(prepared.inputs); + const nodeBuiltins = normalizeNodeBuiltins(prepared.nodeExternals, "prepared.nodeExternals"); + const declaredTypecheckMode = normalizeType(prepared.typecheckMode, "prepared.typecheckMode"); + + const descriptor = assertRecord(prepared.closureDescriptor, "prepared.closureDescriptor"); + assertExactKeys(descriptor, "prepared.closureDescriptor", [ + "build_contract", + "entry", + "esbuild_version", + "inputs", + "preparation_policy", + "typecheck_mode", + "typescript_version", + "used_node_builtins" + ]); + + if (compareUtf16(canonicalJson(descriptor.build_contract), canonicalJson(DYNAMICS_BUILD_CONTRACT)) !== 0) { + fail("prepared.closureDescriptor.build_contract is not canonical"); + } + if (compareUtf16(canonicalJson(descriptor.preparation_policy), canonicalJson(closurePreparationPolicy)) !== 0) { + fail("prepared.closureDescriptor.preparation_policy is not canonical"); + } + + const entry = assertPortableRelative(descriptor.entry, "prepared.closureDescriptor.entry"); + if (entry !== assertPortableRelative(prepared.module, "prepared.module")) fail("prepared.closureDescriptor.entry mismatch"); + + const closureInputs = normalizePreparedInputs(descriptor.inputs); + if (compareUtf16(canonicalJson(closureInputs), canonicalJson(inputs)) !== 0) { + fail("prepared.closureDescriptor.inputs mismatch prepared inputs"); + } + + const closureBuiltins = normalizeNodeBuiltins(descriptor.used_node_builtins, "prepared.closureDescriptor.used_node_builtins"); + if (compareUtf16(canonicalJson(closureBuiltins), canonicalJson(nodeBuiltins)) !== 0) { + fail("prepared.closureDescriptor.used_node_builtins mismatch"); + } + + const preparedTypecheckMode = normalizeType(descriptor.typecheck_mode, "prepared.closureDescriptor.typecheck_mode"); + if (preparedTypecheckMode !== declaredTypecheckMode) { + fail("prepared.typecheckMode mismatch prepared.closureDescriptor.typecheck_mode"); + } + + return deepFreeze({ + artifactBytes: deepFreeze(artifactBytes), + artifactSha256: declaredArtifactSha256, + closureSha256: assertSha256(prepared.closureSha256, "prepared.closureSha256"), + inputs, + nodeBuiltins, + closure: { + entry, + esbuildVersion: assertType(descriptor.esbuild_version, "prepared.closureDescriptor.esbuild_version"), + inputs, + preparedTypecheckMode, + typescriptVersion: assertType(descriptor.typescript_version, "prepared.closureDescriptor.typescript_version"), + usedNodeBuiltins: closureBuiltins + } + }); +}; diff --git a/src/dynamics/buildRuntimeTypes.test.ts b/src/dynamics/buildRuntimeTypes.test.ts new file mode 100644 index 0000000..b8dd06a --- /dev/null +++ b/src/dynamics/buildRuntimeTypes.test.ts @@ -0,0 +1,144 @@ +import assert from "node:assert/strict"; +import { realpath, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { prepareDynamicsBuildWithSourceSnapshot } from "./build.js"; +import { + createBuildTestProject, + prepareBuild, + removeBuildTestPaths, + writeBuildFile +} from "./buildTestSupport.test-helper.js"; +import { sha256 } from "./buildIdentity.js"; +import { runtimePackageNameForTypes } from "./buildPackagePolicy.js"; +import { + createDynamicsBuildSourceSnapshot, + type DynamicsBuildSourceSnapshot +} from "./buildSourceSnapshot.js"; + +const mutateAfterFirstTextRead = ( + target: string, + replacement: string +): Readonly<{ + didMutate: () => boolean; + snapshot: DynamicsBuildSourceSnapshot; +}> => { + const retained = createDynamicsBuildSourceSnapshot(); + let mutated = false; + return { + didMutate: () => mutated, + snapshot: { + ...retained, + readText: async (fileName) => { + const text = await retained.readText(fileName); + if (!mutated && path.resolve(fileName) === path.resolve(target)) { + mutated = true; + await writeFile(target, replacement); + } + return text; + } + } + }; +}; + +describe("dynamics runtime and declaration package resolution", () => { + it("maps DefinitelyTyped ownership to exact runtime package names", () => { + assert.equal(runtimePackageNameForTypes("matter-js"), "matter-js"); + assert.equal(runtimePackageNameForTypes("@types/matter-js"), "matter-js"); + assert.equal(runtimePackageNameForTypes("@types/noopolis__engine"), "@noopolis/engine"); + }); + + it("seals executable bytes and separate declaration evidence", async () => { + const project = await createBuildTestProject(); + try { + const runtimePackage = path.join(project.directory, "node_modules", "typed-runtime"); + const declarationPackage = path.join( + project.directory, + "node_modules", + "@types", + "typed-runtime" + ); + const runtimeSource = "exports.value = 1;\n"; + const declarationSource = "export const value: number;\n"; + await writeBuildFile( + { directory: runtimePackage }, + "package.json", + JSON.stringify({ + name: "typed-runtime", + version: "1.0.0", + type: "commonjs", + main: "./index.js" + }) + ); + await writeBuildFile( + { directory: runtimePackage }, + "index.js", + runtimeSource + ); + await writeBuildFile( + { directory: declarationPackage }, + "package.json", + JSON.stringify({ + name: "@types/typed-runtime", + version: "1.0.0", + types: "./index.d.ts" + }) + ); + await writeBuildFile( + { directory: declarationPackage }, + "index.d.ts", + declarationSource + ); + await writeBuildFile( + project, + "systems/provider.ts", + "import { value } from 'typed-runtime'; export const output: number = value;\n" + ); + + const prepared = await prepareBuild(project, "./systems/provider.ts"); + const runtimeInput = prepared.inputs.find((input) => + input.kind === "package" + && input.package_name === "typed-runtime" + && input.package_path === "./index.js" + && input.modes.includes("runtime") + ); + const declarationInput = prepared.inputs.find((input) => + input.kind === "package" + && input.package_name === "@types/typed-runtime" + && input.package_path === "./index.d.ts" + && input.modes.includes("type-only") + ); + assert.ok(runtimeInput?.kind === "package"); + assert.ok(declarationInput?.kind === "package"); + assert.equal(runtimeInput.sha256, sha256(runtimeSource)); + assert.equal(declarationInput.sha256, sha256(declarationSource)); + + for (const [target, original, replacement] of [ + [path.join(runtimePackage, "index.js"), runtimeSource, "exports.value = 2;\n"], + [ + path.join(declarationPackage, "index.d.ts"), + declarationSource, + "export const value: string;\n" + ] + ] as const) { + await writeFile(target, original); + const canonicalTarget = await realpath(target); + const mutation = mutateAfterFirstTextRead(canonicalTarget, replacement); + await assert.rejects( + prepareDynamicsBuildWithSourceSnapshot( + project.simfilePath, + "./systems/provider.ts", + mutation.snapshot + ), + /source changed during preparation/u, + `must reject mutation of ${canonicalTarget}` + ); + assert.equal(mutation.didMutate(), true); + await writeFile(target, original); + } + } finally { + await removeBuildTestPaths(project.directory); + } + }); +}); diff --git a/src/dynamics/buildSourceSnapshot.test.ts b/src/dynamics/buildSourceSnapshot.test.ts new file mode 100644 index 0000000..cda082a --- /dev/null +++ b/src/dynamics/buildSourceSnapshot.test.ts @@ -0,0 +1,53 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { createDynamicsBuildSourceSnapshot } from "./buildSourceSnapshot.js"; + +describe("dynamics build source snapshot", () => { + it("retains first-observed bytes and rejects cross-phase mutation", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "simfile-build-snapshot-")); + const fileName = path.join(directory, "provider.ts"); + try { + const snapshot = createDynamicsBuildSourceSnapshot(); + await writeFile(fileName, "export const value = 1;\n"); + assert.equal(await snapshot.readText(fileName), "export const value = 1;\n"); + const exposed = await snapshot.readRetainedBytes(fileName); + assert.equal( + Buffer.from(exposed).toString("utf8"), + "export const value = 1;\n" + ); + exposed[0] = 0; + assert.equal( + Buffer.from(await snapshot.readRetainedBytes(fileName)).toString("utf8"), + "export const value = 1;\n" + ); + + await writeFile(fileName, "export const value = 2;\n"); + await assert.rejects(snapshot.readBytes(fileName), /changed during preparation/u); + await assert.rejects(snapshot.verifyAll(), /changed during preparation/u); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); + + it("distinguishes an absent candidate from removal of retained evidence", async () => { + const directory = await mkdtemp(path.join(os.tmpdir(), "simfile-build-snapshot-")); + const fileName = path.join(directory, "types.d.ts"); + try { + const snapshot = createDynamicsBuildSourceSnapshot(); + assert.equal(snapshot.readTextSync(fileName), undefined); + await writeFile(fileName, "export type Value = number;\n"); + assert.match(snapshot.readTextSync(fileName) ?? "", /Value/u); + await unlink(fileName); + assert.throws( + () => snapshot.readTextSync(fileName), + /changed during preparation/u + ); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/src/dynamics/buildSourceSnapshot.ts b/src/dynamics/buildSourceSnapshot.ts new file mode 100644 index 0000000..44de087 --- /dev/null +++ b/src/dynamics/buildSourceSnapshot.ts @@ -0,0 +1,65 @@ +import { readFileSync } from "node:fs"; +import { readFile as readFileAsync } from "node:fs/promises"; +import path from "node:path"; + +import { compareUtf16 } from "./buildIdentity.js"; + +export interface DynamicsBuildSourceSnapshot { + readonly readBytes: (fileName: string) => Promise; + readonly readRetainedBytes: (fileName: string) => Promise; + readonly readText: (fileName: string) => Promise; + readonly readTextSync: (fileName: string) => string | undefined; + readonly verifyAll: () => Promise; +} + +const changed = (fileName: string): Error => + new Error(`dynamics build source changed during preparation: ${fileName}`); + +/** Retains the first bytes observed and rejects every later cross-phase change. */ +export const createDynamicsBuildSourceSnapshot = (): DynamicsBuildSourceSnapshot => { + const retained = new Map(); + const observe = (fileName: string, bytes: Uint8Array): Buffer => { + const candidate = path.resolve(fileName); + const existing = retained.get(candidate); + if (existing) { + if (!existing.equals(bytes)) throw changed(candidate); + return existing; + } + const snapshot = Buffer.from(bytes); + retained.set(candidate, snapshot); + return snapshot; + }; + const readBytes = async (fileName: string): Promise => + Buffer.from(observe(fileName, await readFileAsync(fileName))); + const readTextSync = (fileName: string): string | undefined => { + try { + return observe(fileName, readFileSync(fileName)).toString("utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + if (retained.has(path.resolve(fileName))) throw changed(path.resolve(fileName)); + return undefined; + } + }; + return { + readBytes, + readRetainedBytes: async (fileName) => { + const candidate = path.resolve(fileName); + const bytes = retained.get(candidate); + if (!bytes) throw new Error(`dynamics build source lacks retained bytes: ${candidate}`); + return Buffer.from(bytes); + }, + readText: async (fileName) => + new TextDecoder().decode(await readBytes(fileName)), + readTextSync, + verifyAll: async () => { + for (const fileName of [...retained.keys()].sort(compareUtf16)) { + try { + observe(fileName, await readFileAsync(fileName)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + throw changed(fileName); + } + } + } + }; +}; diff --git a/src/dynamics/buildStaticCommonJsPolicy.ts b/src/dynamics/buildStaticCommonJsPolicy.ts new file mode 100644 index 0000000..fcb71ef --- /dev/null +++ b/src/dynamics/buildStaticCommonJsPolicy.ts @@ -0,0 +1,314 @@ +import ts from "typescript"; + +import type { StaticClosurePolicy } from "./buildStaticPolicy.js"; + +const has = (values: readonly string[], value: string): boolean => values.includes(value); + +export interface PinnedCommonJsInitializers { + readonly declarations: ReadonlySet; + readonly getOwnPropNames: ReadonlySet; + readonly requireDefinitions: ReadonlySet; +} + +const isIdentifierNamed = (node: ts.Node | undefined, name: string): node is ts.Identifier => + node !== undefined && ts.isIdentifier(node) && node.text === name; + +const isCleanIdentifierBinding = (binding: ts.ParameterDeclaration | ts.VariableDeclaration, name: string): boolean => + isIdentifierNamed(binding.name, name) + && (!ts.isVariableDeclaration(binding) || binding.exclamationToken === undefined) + && binding.type === undefined + && binding.initializer === undefined; + +const isCleanIdentifierParameter = (parameter: ts.ParameterDeclaration, name: string): boolean => + isCleanIdentifierBinding(parameter, name) + && parameter.dotDotDotToken === undefined + && parameter.questionToken === undefined + && parameter.modifiers === undefined; + +const isPlainTopLevelVarDeclaration = (declaration: ts.VariableDeclaration): boolean => { + const list = declaration.parent; + const statement = list.parent; + return ts.isVariableDeclarationList(list) + && list.declarations.length === 1 + && (list.flags & ts.NodeFlags.BlockScoped) === 0 + && ts.isVariableStatement(statement) + && statement.modifiers === undefined + && ts.isSourceFile(statement.parent); +}; + +const hasCleanParameters = (parameters: readonly ts.ParameterDeclaration[], names: readonly string[]): boolean => + parameters.length === names.length && parameters.every((parameter, index) => + isCleanIdentifierParameter(parameter, names[index] as string)); + +const isUnmodifiedFunction = (functionLike: ts.ArrowFunction | ts.FunctionExpression | ts.MethodDeclaration): boolean => + ts.getModifiers(functionLike)?.length === undefined && functionLike.typeParameters === undefined; + +const unwrapComposedExpression = (expression: ts.Expression): ts.Expression => { + let current = expression; + while ( + ts.isParenthesizedExpression(current) + || ts.isAsExpression(current) + || ts.isTypeAssertionExpression(current) + || ts.isNonNullExpression(current) + ) { + current = current.expression; + } + return current; +}; + +const flattenCommaExpression = (expression: ts.Expression): ts.Expression[] => { + const unwrapped = unwrapComposedExpression(expression); + const parts: ts.Expression[] = []; + const visit = (candidate: ts.Expression): void => { + const current = unwrapComposedExpression(candidate); + if (ts.isBinaryExpression(current) && current.operatorToken.kind === ts.SyntaxKind.CommaToken) { + visit(current.left); + visit(current.right); + } else { + parts.push(current); + } + }; + visit(unwrapped); + return parts; +}; + +const isNumericZero = (expression: ts.Expression): boolean => ts.isNumericLiteral(expression) && expression.text === "0"; + +const isObjectExportsLiteral = (expression: ts.ObjectLiteralExpression): boolean => { + if (expression.properties.length !== 1) return false; + const [property] = expression.properties; + return ts.isPropertyAssignment(property) + && isIdentifierNamed(property.name, "exports") + && ts.isObjectLiteralExpression(property.initializer) + && property.initializer.properties.length === 0; +}; + +const isCommonJsModuleFactory = (method: ts.MethodDeclaration): boolean => { + if (method.parameters.length < 1 || method.parameters.length > 2) return false; + if (method.asteriskToken !== undefined || method.questionToken !== undefined || method.name === undefined || ts.isComputedPropertyName(method.name)) return false; + if (!isUnmodifiedFunction(method)) return false; + return hasCleanParameters(method.parameters, method.parameters.length === 1 ? ["exports"] : ["exports", "module"]); +}; + +export const isCommonJsInitializerArgument = (expression: ts.Expression): boolean => { + if (!ts.isObjectLiteralExpression(expression) || expression.properties.length !== 1) return false; + const [entry] = expression.properties; + return ts.isMethodDeclaration(entry) && isCommonJsModuleFactory(entry); +}; + +const isPinnedCommonJsResolverCallee = (expression: ts.Expression): ts.Identifier | undefined => { + const operands = flattenCommaExpression(unwrapComposedExpression(expression)); + if (operands.length !== 2 || !isNumericZero(operands[0])) return undefined; + const element = operands[1]; + if (!ts.isElementAccessExpression(element) || element.questionDotToken !== undefined || !isIdentifierNamed(element.expression, "cb")) return undefined; + if (!ts.isElementAccessExpression(element.argumentExpression) || element.argumentExpression.questionDotToken !== undefined) return undefined; + const call = element.argumentExpression.expression; + const index = element.argumentExpression.argumentExpression; + if (!ts.isCallExpression(call) || call.arguments.length !== 1) return undefined; + if (call.typeArguments !== undefined || call.questionDotToken !== undefined) return undefined; + if (!isIdentifierNamed(call.expression, "__getOwnPropNames")) return undefined; + return isIdentifierNamed(call.arguments[0], "cb") && isNumericZero(index) ? call.expression : undefined; +}; + +const isPinnedCommonJsResolverCall = (expression: ts.Expression): ts.Identifier | undefined => { + const unwrapped = unwrapComposedExpression(expression); + if (!ts.isCallExpression(unwrapped)) return undefined; + if (unwrapped.typeArguments !== undefined || unwrapped.questionDotToken !== undefined) return undefined; + if (unwrapped.arguments.length !== 2 || !isInitializedModExports(unwrapped.arguments[0]) || !isIdentifierNamed(unwrapped.arguments[1], "mod")) return undefined; + return isPinnedCommonJsResolverCallee(unwrapComposedExpression(unwrapped.expression)); +}; + +const isModExports = (expression: ts.Expression): boolean => + ts.isPropertyAccessExpression(expression) + && expression.questionDotToken === undefined + && isIdentifierNamed(expression.expression, "mod") + && isIdentifierNamed(expression.name, "exports"); + +const isInitializedModExports = (expression: ts.Expression): boolean => { + if (!ts.isPropertyAccessExpression(expression) || expression.questionDotToken !== undefined || !isIdentifierNamed(expression.name, "exports")) return false; + const assigned = unwrapComposedExpression(expression.expression); + return ts.isBinaryExpression(assigned) + && assigned.operatorToken.kind === ts.SyntaxKind.EqualsToken + && isIdentifierNamed(assigned.left, "mod") + && ts.isObjectLiteralExpression(assigned.right) + && isObjectExportsLiteral(assigned.right); +}; + +const isPinnedCommonJsInitializerReturn = ( + expression: ts.Expression | undefined +): ts.Identifier | undefined => { + if (expression === undefined) return undefined; + const parts = flattenCommaExpression(expression); + if (parts.length !== 2) return undefined; + const [resolverPart, terminalPart] = parts; + if (!isModExports(terminalPart)) return undefined; + if (!ts.isBinaryExpression(resolverPart)) return undefined; + if (resolverPart.operatorToken.kind !== ts.SyntaxKind.BarBarToken) return undefined; + if (!isIdentifierNamed(resolverPart.left, "mod")) return undefined; + return isPinnedCommonJsResolverCall(resolverPart.right); +}; + +const isPinnedCommonJsInitializerCatch = (clause: ts.CatchClause | undefined): boolean => { + if (clause === undefined || clause.variableDeclaration === undefined || !isCleanIdentifierBinding(clause.variableDeclaration, "e")) return false; + if (clause.block.statements.length !== 1 || !ts.isThrowStatement(clause.block.statements[0])) return false; + const thrown = flattenCommaExpression(clause.block.statements[0].expression); + return ( + thrown.length === 2 + && isIdentifierNamed(thrown[1], "e") + && ts.isBinaryExpression(thrown[0]) + && thrown[0].operatorToken.kind === ts.SyntaxKind.EqualsToken + && isIdentifierNamed(thrown[0].left, "mod") + && isNumericZero(thrown[0].right) + ); +}; + +const isGetOwnPropNamesDeclaration = (declaration: ts.Node): ts.Identifier | undefined => { + if (!ts.isVariableDeclaration(declaration)) return undefined; + if (!isIdentifierNamed(declaration.name, "__getOwnPropNames")) return undefined; + if (declaration.exclamationToken !== undefined || declaration.type !== undefined || declaration.initializer === undefined || !ts.isPropertyAccessExpression(declaration.initializer)) return undefined; + if (!isPlainTopLevelVarDeclaration(declaration)) return undefined; + if (declaration.initializer.questionDotToken !== undefined) return undefined; + if (!isIdentifierNamed(declaration.initializer.expression, "Object")) return undefined; + return declaration.initializer.name.text === "getOwnPropertyNames" ? declaration.name : undefined; +}; + +const pinnedCopyPropsReference = (node: ts.Node): ts.Identifier | undefined => { + if (!ts.isCallExpression(node) || node.typeArguments !== undefined || node.questionDotToken !== undefined + || !isIdentifierNamed(node.expression, "__getOwnPropNames") || node.arguments.length !== 1 + || !isIdentifierNamed(node.arguments[0], "from")) return undefined; + const loop = node.parent; + const block = loop.parent; + const guard = block.parent; + const guardedBlock = guard.parent; + const arrow = guardedBlock.parent; + const declaration = arrow.parent; + if (!ts.isForOfStatement(loop) || loop.expression !== node || !ts.isBlock(block) || block.statements.length !== 1 + || !ts.isIfStatement(guard) || guard.thenStatement !== block || !ts.isBlock(guardedBlock) + || !ts.isArrowFunction(arrow) || arrow.body !== guardedBlock || !ts.isVariableDeclaration(declaration) + || !isPlainTopLevelVarDeclaration(declaration) || !isIdentifierNamed(declaration.name, "__copyProps") + || declaration.initializer !== arrow || !isUnmodifiedFunction(arrow) + || !hasCleanParameters(arrow.parameters, ["to", "from", "except", "desc"])) return undefined; + return node.expression; +}; + +const pinnedEsmReference = (node: ts.Node): ts.Identifier | undefined => { + if (!ts.isCallExpression(node) || node.typeArguments !== undefined || node.questionDotToken !== undefined + || !isIdentifierNamed(node.expression, "__getOwnPropNames") || node.arguments.length !== 1 + || !isIdentifierNamed(node.arguments[0], "fn") || !ts.isElementAccessExpression(node.parent) + || node.parent.expression !== node || !isNumericZero(node.parent.argumentExpression) + || !ts.isElementAccessExpression(node.parent.parent) || node.parent.parent.argumentExpression !== node.parent + || !isIdentifierNamed(node.parent.parent.expression, "fn")) return undefined; + let current: ts.Node | undefined = node.parent.parent; + while (current !== undefined && (!ts.isVariableDeclaration(current) || !isPlainTopLevelVarDeclaration(current))) current = current.parent; + if (current === undefined || !ts.isVariableDeclaration(current)) return undefined; + if (!isIdentifierNamed(current.name, "__esm") || current.initializer === undefined || !ts.isArrowFunction(current.initializer)) return undefined; + if (!isUnmodifiedFunction(current.initializer) + || !hasCleanParameters(current.initializer.parameters, ["fn", "res", "err"]) + || !ts.isFunctionExpression(current.initializer.body) || current.initializer.body.asteriskToken !== undefined + || !isIdentifierNamed(current.initializer.body.name, "__init") || !hasCleanParameters(current.initializer.body.parameters, [])) return undefined; + if (!isUnmodifiedFunction(current.initializer.body)) return undefined; + return node.expression; +}; + +const isCommonJsInitializerDefinition = ( + declaration: ts.VariableDeclaration, + policy: StaticClosurePolicy +): ts.Identifier | undefined => { + if (!isPlainTopLevelVarDeclaration(declaration)) return undefined; + if (!ts.isIdentifier(declaration.name)) return undefined; + const declarationName = declaration.name.text; + if (declarationName !== "__commonJS" && !has(policy.emitted.commonJsInitializerIdentifiers, declarationName)) return undefined; + if (declaration.exclamationToken !== undefined || declaration.type !== undefined || declaration.initializer === undefined || !ts.isArrowFunction(declaration.initializer)) { + return undefined; + } + if (!isUnmodifiedFunction(declaration.initializer) || !hasCleanParameters(declaration.initializer.parameters, ["cb", "mod"])) { + return undefined; + } + if (!ts.isFunctionExpression(declaration.initializer.body)) return undefined; + if (!isUnmodifiedFunction(declaration.initializer.body) || declaration.initializer.body.asteriskToken !== undefined + || !isIdentifierNamed(declaration.initializer.body.name, "__require") || !hasCleanParameters(declaration.initializer.body.parameters, [])) return undefined; + + if (!ts.isBlock(declaration.initializer.body.body) || declaration.initializer.body.body.statements.length !== 1) return undefined; + const tryStatement = declaration.initializer.body.body.statements[0]; + if (!ts.isTryStatement(tryStatement) || tryStatement.finallyBlock !== undefined || tryStatement.tryBlock.statements.length !== 1) return undefined; + const returnStatement = tryStatement.tryBlock.statements[0]; + if (!ts.isReturnStatement(returnStatement) || !isPinnedCommonJsInitializerCatch(tryStatement.catchClause)) return undefined; + return isPinnedCommonJsInitializerReturn(returnStatement.expression); +}; + +const collectPinnedCommonJsInitializerDeclarations = (node: ts.Node, policy: StaticClosurePolicy): PinnedCommonJsInitializers => { + const getOwnPropNamesDeclarations = new Set(); + const getOwnPropNames = new Set(); + const candidates = new Map(); + const visitForGetOwn = (child: ts.Node): void => { + const declared = isGetOwnPropNamesDeclaration(child); + if (declared !== undefined) getOwnPropNamesDeclarations.add(declared); + ts.forEachChild(child, visitForGetOwn); + }; + const visitForDefinitions = (child: ts.Node): void => { + if (ts.isVariableDeclaration(child)) { + const reference = isCommonJsInitializerDefinition(child, policy); + if (reference !== undefined) { + const name = (child.name as ts.Identifier).text; + candidates.set(name, [...(candidates.get(name) ?? []), child.name as ts.Identifier]); + getOwnPropNames.add(reference); + } + } + const copyPropsReference = pinnedCopyPropsReference(child); + if (copyPropsReference !== undefined) getOwnPropNames.add(copyPropsReference); + const esmReference = pinnedEsmReference(child); + if (esmReference !== undefined) getOwnPropNames.add(esmReference); + ts.forEachChild(child, visitForDefinitions); + }; + visitForGetOwn(node); + visitForDefinitions(node); + const declarations = new Set([...candidates.values()].flatMap((candidates) => candidates.length === 1 ? candidates : [])); + return { + declarations, + getOwnPropNames: getOwnPropNamesDeclarations.size === 1 && getOwnPropNames.size > 0 ? new Set([...getOwnPropNamesDeclarations, ...getOwnPropNames]) : new Set(), + requireDefinitions: new Set(), + }; +}; + +export const isCommonJsCandidateIdentifier = (identifier: string, policy: StaticClosurePolicy): boolean => { + if (has(policy.emitted.commonJsInitializerIdentifiers, identifier)) return true; + if (policy.emitted.commonJsInitializerIdentifiers.some((identifierLike) => identifier.includes(identifierLike))) return true; + return new RegExp(policy.emitted.commonJsReservedIdentifierPattern, "iu").test(identifier); +}; + +export const collectPinnedCommonJsInitializerIdentifiers = ( + node: ts.Node, + policy: StaticClosurePolicy +): PinnedCommonJsInitializers => { + const pinned = collectPinnedCommonJsInitializerDeclarations(node, policy); + const requireDefinitions = new Set(); + const declarationNames = new Set([...pinned.declarations].map((identifier) => identifier.text)); + const visit = (child: ts.Node): void => { + if (ts.isVariableDeclaration(child) && ts.isIdentifier(child.name) && child.initializer !== undefined && declarationNames.has(child.name.text)) { + if (ts.isArrowFunction(child.initializer) && ts.isFunctionExpression(child.initializer.body)) { + const requireDefinition = child.initializer.body.name; + if (requireDefinition !== undefined && ts.isIdentifier(requireDefinition) && requireDefinition.text === "__require") { + requireDefinitions.add(requireDefinition); + } + } + } + ts.forEachChild(child, visit); + }; + visit(node); + return { ...pinned, requireDefinitions }; +}; + +export const isExactCommonJsInitializerCall = ( + node: ts.CallExpression, + policy: StaticClosurePolicy, + declarations: ReadonlySet +): boolean => { + if (node.typeArguments !== undefined) return false; + if (node.questionDotToken !== undefined) return false; + if (!ts.isIdentifier(node.expression)) return false; + const initializerName = node.expression.text; + if (!has(policy.emitted.commonJsInitializerIdentifiers, initializerName)) return false; + if (![...declarations].some((declaration) => declaration.text === initializerName)) return false; + if (node.arguments.length !== 1) return false; + return isCommonJsInitializerArgument(node.arguments[0]); +}; diff --git a/src/dynamics/buildStaticCompilerHostPolicy.test.ts b/src/dynamics/buildStaticCompilerHostPolicy.test.ts new file mode 100644 index 0000000..c14acb8 --- /dev/null +++ b/src/dynamics/buildStaticCompilerHostPolicy.test.ts @@ -0,0 +1,313 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { mkdir, mkdtemp, rm, symlink, unlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; +import ts from "typescript"; + +import { createStaticCompilerReadGuard } from "./buildStaticCompilerHostPolicy.js"; + +interface CompilerHostTestPaths { + known: string; + entry: string; + alias: string; + replacement: string; + defaultRoot: string; + manifest: string; + outsideManifest: string; + recorded: string; + unrecorded: string; + source: string; + present: string; + absent: string; + directory: string; + symlinked: string; + late: string; + lateIndex: string; + packageJson: string; +} + +const compilerOptions: ts.CompilerOptions = { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + noLib: true, + target: ts.ScriptTarget.ES2022 +}; + +const withCompilerRoots = async (run: (root: string, outside: string) => Promise): Promise => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-compiler-")); + const outside = await mkdtemp(path.join(os.tmpdir(), "simfile-compiler-outside-")); + try { + return await run(root, outside); + } finally { + await rm(root, { recursive: true, force: true }); + await rm(outside, { recursive: true, force: true }); + } +}; + +const createTrackedCompilerDelegate = (overrides: { fileExists?: (fileName: string) => boolean; readFile?: (fileName: string) => string; } = {}): { + counts: { fileExists: number; readFile: number }; + delegate: ts.ModuleResolutionHost; +} => { + const counts = { fileExists: 0, readFile: 0 }; + return { + counts, + delegate: { + fileExists: (fileName) => { counts.fileExists += 1; return overrides.fileExists?.(fileName) ?? true; }, + readFile: (fileName) => { + counts.readFile += 1; + if (overrides.readFile) return overrides.readFile(fileName); + try { + return readFileSync(fileName, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + } + } + }; +}; + +const setupCompilerHostFixture = async (root: string, outside: string): Promise => { + const rootDirectory = path.join(root, "node_modules", "@types"); + const paths: CompilerHostTestPaths = { + known: path.join(root, "known.d.ts"), + entry: path.join(root, "entry.ts"), + alias: path.join(root, "alias"), + replacement: path.join(root, "replacement.d.ts"), + defaultRoot: path.join(root, "default-root"), + manifest: path.join(root, "node_modules", "safe", "package.json"), + outsideManifest: path.join(outside, "package.json"), + recorded: path.join(root, "recorded.d.ts"), + unrecorded: path.join(root, "unrecorded.d.ts"), + source: path.join(root, "source.d.ts"), + present: path.join(root, "default-root", "present.d.ts"), + absent: path.join(root, "default-root", "absent.d.ts"), + directory: path.join(root, "default-root", "directory"), + symlinked: path.join(root, "default-root", "symlinked.d.ts"), + late: path.join(root, "late.d.ts"), + lateIndex: path.join(rootDirectory, "late", "index.d.ts"), + packageJson: path.join(rootDirectory, "package.json") + }; + + await mkdir(paths.defaultRoot, { recursive: true }); + await Promise.all([ + mkdir(paths.directory), + mkdir(path.dirname(paths.lateIndex), { recursive: true }), + mkdir(path.dirname(paths.manifest), { recursive: true }) + ]); + + await Promise.all([ + writeFile(paths.known, "export type Known = number;\n"), + writeFile(paths.entry, "export type Entry = 1;\n"), + writeFile(paths.late, "export type Late = number;\n"), + writeFile(paths.source, "export type Value = number;\n"), + writeFile(paths.present, "export type Present = number;\n"), + writeFile(paths.recorded, "export type Recorded = number;\n"), + writeFile(paths.unrecorded, "export type Unrecorded = number;\n"), + writeFile(paths.replacement, "export type Replacement = number;\n"), + writeFile(paths.outsideManifest, "{}", "utf8"), + writeFile(paths.lateIndex, "export type Late = 1;\n"), + writeFile(paths.packageJson, "{}", "utf8") + ]); + await symlink(paths.outsideManifest, paths.manifest); + await symlink(root, paths.alias); + await symlink(paths.replacement, paths.symlinked); + return paths; +}; + +describe("static compiler host read guard policy", () => { + it("rejects preflight read of a symlinked package manifest before delegate read", async () => { + await withCompilerRoots(async (root, outside) => { + const paths = await setupCompilerHostFixture(root, outside); + const delegate = createTrackedCompilerDelegate(); + const guard = createStaticCompilerReadGuard([root], outside, delegate.delegate); + + assert.throws(() => guard.preflightResolutionHost.readFile?.(paths.manifest), /symlink/i); + assert.throws(() => guard.preflightResolutionHost.readFile?.(path.join(root, "alias", "source.d.ts")), /symlink/i); + assert.deepEqual(delegate.counts, { fileExists: 0, readFile: 0 }); + }); + }); + + it("hides out-of-authority resolution candidates without reading them", async () => { + await withCompilerRoots(async (root, outside) => { + const paths = await setupCompilerHostFixture(root, outside); + const delegate = createTrackedCompilerDelegate(); + const guard = createStaticCompilerReadGuard([root], paths.defaultRoot, delegate.delegate); + + assert.equal(guard.preflightResolutionHost.fileExists?.(paths.outsideManifest), false); + assert.throws( + () => guard.preflightResolutionHost.readFile?.(paths.outsideManifest), + /outside approved lexical roots/u + ); + assert.deepEqual(delegate.counts, { fileExists: 0, readFile: 0 }); + }); + }); + + it("validates the most specific approved anchor and rejects a symlinked approved anchor", async () => { + await withCompilerRoots(async (root, outside) => { + const paths = await setupCompilerHostFixture(root, outside); + const nestedAnchor = path.join(root, "specific-anchor"); + const nestedFile = path.join(nestedAnchor, "specific.d.ts"); + + await mkdir(nestedAnchor); + await writeFile(nestedFile, "export type Specific = number;\n"); + + const nestedDelegate = createTrackedCompilerDelegate(); + const nestedGuard = createStaticCompilerReadGuard([root, nestedAnchor], outside, nestedDelegate.delegate); + assert.doesNotThrow(() => nestedGuard.assertPreflightRead(nestedFile)); + assert.deepEqual(nestedDelegate.counts, { fileExists: 0, readFile: 0 }); + + const symlinkedAnchor = path.join(root, "nested-anchor-link"); + await symlink(nestedAnchor, symlinkedAnchor); + + const symlinkedDelegate = createTrackedCompilerDelegate(); + const symlinkedGuard = createStaticCompilerReadGuard([symlinkedAnchor], outside, symlinkedDelegate.delegate); + assert.throws(() => symlinkedGuard.assertPreflightRead(path.join(symlinkedAnchor, "specific.d.ts")), /approved anchor is not a regular nonsymlink directory|symlink/i); + assert.deepEqual(symlinkedDelegate.counts, { fileExists: 0, readFile: 0 }); + }); + }); + + it("freezes preflight evidence once and returns the same immutable snapshot", async () => { + await withCompilerRoots(async (root, outside) => { + const paths = await setupCompilerHostFixture(root, outside); + const delegate = createTrackedCompilerDelegate(); + const guard = createStaticCompilerReadGuard([root], outside, delegate.delegate); + + guard.assertPreflightRead(paths.recorded); + const first = guard.freezePreflight(); + const second = guard.freezePreflight(); + assert.equal(first, second); + assert.deepEqual(first, [paths.recorded]); + }); + }); + + it("allows a recorded file through checked read delegates after freeze", async () => { + await withCompilerRoots(async (root, outside) => { + const paths = await setupCompilerHostFixture(root, outside); + const delegate = createTrackedCompilerDelegate(); + const guard = createStaticCompilerReadGuard([root], outside, delegate.delegate); + + guard.assertPreflightRead(paths.known); + assert.deepEqual(guard.freezePreflight(), [paths.known]); + assert.equal(guard.checkedResolutionHost.fileExists?.(paths.known), true); + assert.equal(guard.checkedResolutionHost.readFile?.(paths.known), "export type Known = number;\n"); + assert.deepEqual(delegate.counts, { fileExists: 1, readFile: 1 }); + }); + }); + + it("never reads unknown existing or absent non-default candidates through checked delegates", async () => { + await withCompilerRoots(async (root, outside) => { + const paths = await setupCompilerHostFixture(root, outside); + const delegate = createTrackedCompilerDelegate(); + const guard = createStaticCompilerReadGuard([root], outside, delegate.delegate); + + guard.assertPreflightRead(paths.recorded); + guard.freezePreflight(); + assert.equal(guard.checkedResolutionHost.fileExists?.(paths.unrecorded), false); + assert.equal(guard.checkedResolutionHost.fileExists?.(path.join(root, "absent.d.ts")), false); + assert.throws(() => guard.checkedResolutionHost.readFile?.(paths.unrecorded), /immutable preflight evidence/u); + assert.equal(delegate.counts.fileExists, 0); + assert.equal(delegate.counts.readFile, 0); + assert.deepEqual(guard.frozenPreflightPaths(), [paths.recorded]); + }); + }); + + it("prevents alternative module and type-reference resolution from delegating or expanding frozen evidence", async () => { + await withCompilerRoots(async (root, outside) => { + const paths = await setupCompilerHostFixture(root, outside); + const rootDirectory = path.dirname(paths.packageJson); + const delegate = createTrackedCompilerDelegate(); + const guard = createStaticCompilerReadGuard([root], outside, delegate.delegate); + const frozenBefore = guard.frozenPreflightPaths(); + + guard.assertPreflightRead(paths.entry); + const frozenEvidence = guard.freezePreflight(); + assert.equal(ts.resolveModuleName( + "./missing", + paths.known, + compilerOptions, + guard.checkedResolutionHost + ).resolvedModule, undefined); + assert.equal(ts.resolveTypeReferenceDirective( + "late", + paths.known, + { ...compilerOptions, typeRoots: [rootDirectory] }, + guard.checkedResolutionHost + ).resolvedTypeReferenceDirective, undefined); + assert.deepEqual(delegate.counts, { fileExists: 0, readFile: 0 }); + assert.equal(frozenBefore, undefined); + assert.deepEqual(guard.frozenPreflightPaths(), frozenEvidence); + }); + }); + + it("blocks checked getSourceFile for an unrecorded root source", async () => { + await withCompilerRoots(async (root, outside) => { + const paths = await setupCompilerHostFixture(root, outside); + const delegate = createTrackedCompilerDelegate(); + const guard = createStaticCompilerReadGuard([root], outside, delegate.delegate); + + guard.assertPreflightRead(paths.recorded); + guard.freezePreflight(); + const baseHost = ts.createCompilerHost(compilerOptions, true); + const baseGetSourceFile = baseHost.getSourceFile!.bind(baseHost); + const guardedHost = { + ...baseHost, + ...guard.checkedResolutionHost, + getSourceFile: (fileName, languageVersion, onError, shouldCreateNewSourceFile) => { + guard.assertCheckedRead(fileName); + return baseGetSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile); + } + } as ts.CompilerHost; + + assert.throws(() => ts.createProgram([paths.late], compilerOptions, guardedHost), /immutable preflight evidence|symlink/); + assert.deepEqual(delegate.counts, { fileExists: 0, readFile: 0 }); + assert.deepEqual(guard.frozenPreflightPaths(), [paths.recorded]); + }); + }); + + it("rejects preflight writes after freeze", async () => { + await withCompilerRoots(async (root, outside) => { + const paths = await setupCompilerHostFixture(root, outside); + const delegate = createTrackedCompilerDelegate(); + const guard = createStaticCompilerReadGuard([root], outside, delegate.delegate); + + guard.assertPreflightRead(paths.recorded); + guard.freezePreflight(); + assert.throws(() => guard.assertPreflightRead(paths.late), /frozen/u); + }); + }); + + it("rejects checked reads when a recorded file has been converted to a symlink", async () => { + await withCompilerRoots(async (root, outside) => { + const paths = await setupCompilerHostFixture(root, outside); + const delegate = createTrackedCompilerDelegate(); + const guard = createStaticCompilerReadGuard([root], outside, delegate.delegate); + + guard.assertPreflightRead(paths.recorded); + guard.freezePreflight(); + await writeFile(paths.replacement, "export type Recorded = string;\n"); + await unlink(paths.recorded); + await symlink(paths.replacement, paths.recorded); + assert.throws(() => guard.checkedResolutionHost.readFile?.(paths.recorded), /symlink/u); + assert.deepEqual(delegate.counts, { fileExists: 0, readFile: 0 }); + }); + }); + + it("lets checked delegates read only regular default-root paths", async () => { + await withCompilerRoots(async (root, outside) => { + const paths = await setupCompilerHostFixture(root, outside); + const delegate = createTrackedCompilerDelegate(); + const guard = createStaticCompilerReadGuard([root], paths.defaultRoot, delegate.delegate); + + for (const candidate of [paths.absent, paths.directory, paths.symlinked, paths.present]) { + const before = delegate.counts.fileExists; + assert.equal(guard.checkedResolutionHost.fileExists?.(candidate), candidate === paths.present); + assert.equal(delegate.counts.fileExists, before + (candidate === paths.present ? 1 : 0)); + } + assert.equal(guard.checkedResolutionHost.readFile?.(paths.present), "export type Present = number;\n"); + assert.equal(delegate.counts.readFile, 1); + }); + }); +}); diff --git a/src/dynamics/buildStaticCompilerHostPolicy.ts b/src/dynamics/buildStaticCompilerHostPolicy.ts new file mode 100644 index 0000000..a536ff6 --- /dev/null +++ b/src/dynamics/buildStaticCompilerHostPolicy.ts @@ -0,0 +1,164 @@ +import { lstatSync } from "node:fs"; +import path from "node:path"; +import ts from "typescript"; + +import { compareUtf16 } from "./buildIdentity.js"; +import { DYNAMICS_BUILD_PREPARATION_POLICY } from "./buildInput.js"; + +export interface StaticCompilerReadGuard { + readonly assertCheckedRead: (fileName: string) => void; + readonly assertPreflightRead: (fileName: string) => void; + readonly freezePreflight: () => readonly string[]; + readonly frozenPreflightPaths: () => (readonly string[] | undefined); + readonly preflightResolutionHost: ts.ModuleResolutionHost; + readonly checkedResolutionHost: ts.ModuleResolutionHost; +} + +const manifestFileName = DYNAMICS_BUILD_PREPARATION_POLICY.package.manifestFileName; +const sourceExtensions = DYNAMICS_BUILD_PREPARATION_POLICY.source.checkedExtensions; + +const isContained = (root: string, candidate: string): boolean => { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +}; + +const existing = (fileName: string): boolean => { + try { lstatSync(fileName); return true; } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +}; + +const canonical = (fileName: string): string => path.resolve(fileName); +const isAllowedRead = (fileName: string): boolean => path.basename(fileName) === manifestFileName + || sourceExtensions.some((extension) => fileName.endsWith(extension)); +type LexicalCandidateOutcome = "absent" | "regular"; + +/** + * Produces mutable preflight and immutable compiler views from one lexical + * authority. Neither view realpaths requests, so symlink evidence survives. + */ +export const createStaticCompilerReadGuard = ( + anchors: readonly string[], + defaultLibraryRoot: string, + delegate: ts.ModuleResolutionHost = ts.sys +): StaticCompilerReadGuard => { + const trusted = [...new Set([...anchors, defaultLibraryRoot].map(canonical))].sort((left, right) => { + const specificity = right.length - left.length; + return specificity || compareUtf16(left, right); + }); + const defaultRoot = canonical(defaultLibraryRoot); + const prevalidated = new Set(); + let frozen: ReadonlySet | undefined; + let frozenPaths: readonly string[] | undefined; + + const approvedAnchor = (candidate: string): string | undefined => + trusted.find((root) => isContained(root, candidate)); + const selectAnchor = (candidate: string): string => { + const anchor = approvedAnchor(candidate); + if (!anchor) throw new Error(`TypeScript attempted a read outside approved lexical roots: ${candidate}`); + return anchor; + }; + + const inspectLexical = (fileName: string, mode: "authorize" | "validate"): LexicalCandidateOutcome => { + const candidate = canonical(fileName); + const anchor = selectAnchor(candidate); + let anchorStat: ReturnType; + try { anchorStat = lstatSync(anchor); } catch { throw new Error(`TypeScript cannot safely inspect approved anchor: ${anchor}`); } + if (anchorStat.isSymbolicLink() || !anchorStat.isDirectory()) { + throw new Error(`TypeScript approved anchor is not a regular nonsymlink directory: ${anchor}`); + } + const fail = (message: string): never => { + throw new Error(message); + }; + let current = anchor; + const relative = path.relative(anchor, candidate).split(path.sep).filter(Boolean); + if (relative.length === 0) { + return mode === "authorize" ? "absent" : fail(`TypeScript cannot safely inspect ${candidate}`); + } + for (let index = 0; index < relative.length; index += 1) { + const part = relative[index]; + current = path.join(current, part); + let stat: ReturnType; + try { stat = lstatSync(current); } catch { + if (mode === "authorize") return "absent"; + throw new Error(`TypeScript cannot safely inspect ${current}`); + } + if (mode === "authorize" && (stat.isSymbolicLink() || !stat.isFile() && index === relative.length - 1)) return "absent"; + if (stat.isSymbolicLink()) throw new Error(`static source path contains a symlink before TypeScript read: ${current}`); + if (index === relative.length - 1) { + if (!stat.isFile()) { + throw new Error(`static source path is not a regular file before TypeScript read: ${current}`); + } + return "regular"; + } + if (!stat.isDirectory()) throw new Error(`static source path has a non-directory ancestor before TypeScript read: ${current}`); + } + return fail(`TypeScript cannot safely inspect ${candidate}`); + }; + + const assertLexicalRegular = (fileName: string): void => { + inspectLexical(fileName, "validate"); + }; + + const assertPreflightRead = (fileName: string): void => { + if (frozen) throw new Error("TypeScript preflight evidence is frozen"); + const candidate = canonical(fileName); + if (!isAllowedRead(candidate)) throw new Error(`TypeScript attempted an unapproved metadata read: ${candidate}`); + assertLexicalRegular(candidate); + prevalidated.add(candidate); + }; + const assertCheckedRead = (fileName: string): void => { + const candidate = canonical(fileName); + if (isContained(defaultRoot, candidate)) return assertLexicalRegular(candidate); + if (!frozen) throw new Error("TypeScript checked read requires frozen preflight evidence"); + if (!frozen.has(candidate)) throw new Error(`TypeScript attempted a compiler read absent from immutable preflight evidence: ${candidate}`); + assertLexicalRegular(candidate); + }; + const fileExists = delegate.fileExists?.bind(delegate) ?? ts.sys.fileExists; + const readFile = delegate.readFile?.bind(delegate) ?? ts.sys.readFile; + const directoryExists = delegate.directoryExists?.bind(delegate) ?? ts.sys.directoryExists; + const isCheckedCandidate = (fileName: string): boolean => { + const candidate = canonical(fileName); + if (isContained(defaultRoot, candidate)) return inspectLexical(candidate, "authorize") === "regular"; + if (!frozen || !frozen.has(candidate)) return false; + assertLexicalRegular(candidate); + return true; + }; + const resolutionHost = ( + assertRead: (fileName: string) => void, + isCandidateAuthorized: (candidate: string) => boolean + ): ts.ModuleResolutionHost => ({ + ...delegate, + directoryExists, + fileExists: (fileName) => { + const candidate = canonical(fileName); + if (!isCandidateAuthorized(candidate)) return false; + return fileExists(candidate); + }, + readFile: (fileName) => { + assertRead(fileName); + return readFile(fileName); + }, + realpath: undefined + }); + return { + assertCheckedRead, + assertPreflightRead, + freezePreflight: () => { + if (!frozen) { + frozenPaths = Object.freeze([...prevalidated].sort(compareUtf16)); + frozen = new Set(frozenPaths); + } + return frozenPaths as readonly string[]; + }, + frozenPreflightPaths: () => frozenPaths, + preflightResolutionHost: resolutionHost(assertPreflightRead, (candidate) => { + if (!approvedAnchor(candidate)) return false; + if (!existing(candidate)) return false; + assertPreflightRead(candidate); + return true; + }), + checkedResolutionHost: resolutionHost(assertCheckedRead, isCheckedCandidate) + }; +}; diff --git a/src/dynamics/buildStaticGraphPolicy.ts b/src/dynamics/buildStaticGraphPolicy.ts new file mode 100644 index 0000000..79cb4e2 --- /dev/null +++ b/src/dynamics/buildStaticGraphPolicy.ts @@ -0,0 +1,74 @@ +import { compareUtf16 } from "./buildIdentity.js"; +import { sha256, type DynamicsBuildInputDescriptor } from "./buildIdentity.js"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { staticSourceSpecifiers, type StaticSourceSpecifier } from "./buildStaticResolverPolicy.js"; +import { DYNAMICS_STATIC_CLOSURE_POLICY } from "./buildStaticPolicy.js"; +import type { PackageIdentity } from "./buildPackagePolicy.js"; + +type InputMode = "runtime" | "type-only"; +type StaticByteReader = (fileName: string) => Promise; +export interface StaticDescriptorSource extends StaticRuntimeGraphSource { + readonly boundary: string; + readonly identity?: PackageIdentity; + readonly kind: "package" | "project"; +} + +const portable = (value: string): string => value.split(path.sep).join("/"); + +export const descriptorForStaticSource = async ( + source: StaticDescriptorSource, + modes: ReadonlySet, + projectRoot: string, + readBytes: StaticByteReader = readFile +): Promise => { + if (source.kind === "project") return { kind: "project", modes: [...modes].sort(compareUtf16), path: `./${portable(path.relative(projectRoot, source.fileName))}`, sha256: sha256(await readBytes(source.fileName)) }; + if (!source.identity) throw new Error(`runtime source has no package identity: ${source.fileName}`); + return { kind: "package", manifest_sha256: source.identity.manifestSha256, modes: [...modes].sort(compareUtf16), package_name: source.identity.name, package_path: `./${portable(path.relative(source.identity.directory, source.fileName))}`, package_version: source.identity.version, sha256: sha256(await readBytes(source.fileName)) }; +}; + +export const staticTypeSurfaceDescriptor = async (files: readonly string[], root: string, identity: PackageIdentity, readBytes: StaticByteReader = readFile): Promise => ({ + files: await Promise.all([...files].sort(compareUtf16).map(async (fileName) => ({ path: `./${portable(path.relative(root, fileName))}`, sha256: sha256(await readBytes(fileName)) }))), + kind: "type-only", manifest_sha256: identity.manifestSha256, package_name: "simfile", package_version: identity.version, surface: "dynamics" +}); + +export const sortStaticInputs = (inputs: readonly DynamicsBuildInputDescriptor[]): DynamicsBuildInputDescriptor[] => + [...inputs].sort((left, right) => compareUtf16(JSON.stringify(left), JSON.stringify(right))); + +export interface StaticRuntimeGraphSource { + readonly fileName: string; +} + +/** Builds immutable runtime-only reachability before esbuild observes files. */ +export const preflightStaticRuntimeGraph = async ( + entries: readonly T[], + allSources: ReadonlyMap, + read: (fileName: string) => Promise, + resolve: (specifier: StaticSourceSpecifier, importer: T) => Promise +): Promise> => { + const runtime = new Set(); + const queued = [...entries]; + for (let index = 0; index < queued.length; index += 1) { + const source = queued[index] as T; + if (runtime.has(source.fileName)) continue; + runtime.add(source.fileName); + for (const specifier of staticSourceSpecifiers(source.fileName, await read(source.fileName), DYNAMICS_STATIC_CLOSURE_POLICY)) { + if (specifier.mode === "type-only") continue; + const fileName = await resolve(specifier, source); + if (fileName === undefined) continue; + const target = allSources.get(fileName); + if (!target) throw new Error(`runtime graph target was not independently preflighted: ${specifier.specifier}`); + queued.push(target); + } + } + return runtime; +}; + +export const assertExactRuntimeInputs = (expected: ReadonlySet, actual: readonly string[]): void => { + const observed = new Set(actual); + const missing = [...expected].filter((fileName) => !observed.has(fileName)).sort(compareUtf16); + const extra = [...observed].filter((fileName) => !expected.has(fileName)).sort(compareUtf16); + if (missing.length || extra.length) { + throw new Error(`runtime preflight evidence does not exactly match esbuild metafile inputs; missing: ${missing.join(", ") || "none"}; extra: ${extra.join(", ") || "none"}`); + } +}; diff --git a/src/dynamics/buildStaticPolicy.test.ts b/src/dynamics/buildStaticPolicy.test.ts new file mode 100644 index 0000000..61b7ae3 --- /dev/null +++ b/src/dynamics/buildStaticPolicy.test.ts @@ -0,0 +1,316 @@ +import assert from "node:assert/strict"; +import { lstat, mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; +import { build } from "esbuild"; + +import { createDynamicsClosureIdentity } from "./buildIdentity.js"; +import { + assertStaticEmittedEsm, + assertStaticSource, + auditStaticMetafileImports, + classifyStaticModuleSpecifier, + DYNAMICS_STATIC_CLOSURE_POLICY, + staticPreserveSymlinks, + type StaticClosurePolicy, + validateStaticSourcePath +} from "./buildStaticPolicy.js"; +import { preflightStaticGraph } from "./buildStaticResolverPolicy.js"; +import { staticSourceSpecifiers } from "./buildStaticResolverPolicy.js"; +import { assertExactRuntimeInputs } from "./buildStaticGraphPolicy.js"; + +const clonePolicy = (): StaticClosurePolicy => JSON.parse(JSON.stringify(DYNAMICS_STATIC_CLOSURE_POLICY)) as StaticClosurePolicy; +const changed = (policy: unknown): StaticClosurePolicy => policy as StaticClosurePolicy; +const identity = (preparationPolicy: unknown): string => createDynamicsClosureIdentity({ + buildContract: {}, entry: "./provider.mjs", esbuildVersion: "1", inputs: [], preparationPolicy, typecheckMode: "none", typescriptVersion: "1", usedNodeBuiltins: [] +}).sha256; + +const buildClosedCommonJsOutput = async (): Promise => { + const result = await build({ + bundle: true, + format: "esm", + platform: "node", + write: false, + stdin: { + contents: "import c from './cdep';\nvar x = await import('./esmdep');\nconsole.log(c.value, x.value);\n", + sourcefile: "index.js", + resolveDir: process.cwd(), + loader: "js", + }, + plugins: [{ + name: "b54-closed-cjs-fixture", + setup(api) { + api.onResolve({ filter: /^\.\/cdep$/ }, () => ({ namespace: "virtual:cdep", path: "cdep" })); + api.onResolve({ filter: /^\.\/esmdep$/ }, () => ({ namespace: "virtual:esmdep", path: "esmdep" })); + api.onLoad({ filter: /^cdep$/, namespace: "virtual:cdep" }, () => ({ contents: "module.exports = { value: 1, default: 2 };\n", loader: "js" })); + api.onLoad({ filter: /^esmdep$/, namespace: "virtual:esmdep" }, () => ({ contents: "export const value = 3;\n", loader: "js" })); + }, + }], + }); + if (result.outputFiles === undefined || result.outputFiles.length === 0) { + throw new Error("static policy fixture does not include emitted output"); + } + return result.outputFiles[0].text; +}; + +const replacePinned = (output: string, expected: string, replacement: string): string => { + assert.equal(output.includes(expected), true, `pinned esbuild output must contain ${expected}`); + const index = output.lastIndexOf(expected); + return `${output.slice(0, index)}${replacement}${output.slice(index + expected.length)}`; +}; + +describe("static source closure policy", () => { + it("deeply freezes and every policy group drives validation", async () => { + const walk = (value: unknown): void => { + if (value && typeof value === "object") { + assert.equal(Object.isFrozen(value), true); + for (const child of Object.values(value as Record)) walk(child); + } + }; + walk(DYNAMICS_STATIC_CLOSURE_POLICY); + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-static-policy-")); + try { + await writeFile(path.join(root, "source.mjs"), "export const value = 1;\n"); + const buildPolicy = changed({ ...clonePolicy(), build: { preserveSymlinks: false } }); + const pathPolicy = changed({ ...clonePolicy(), path: { ...clonePolicy().path, leaf: "other" } }); + const sourcePolicy = changed({ ...clonePolicy(), source: { ...clonePolicy().source, lookupForms: ["dynamic-import"] } }); + const specifierPolicy = changed({ ...clonePolicy(), specifier: { ...clonePolicy().specifier, approvedNodeBuiltins: [] } }); + const metafilePolicy = changed({ ...clonePolicy(), metafile: { ...clonePolicy().metafile, externalFlag: false } }); + const emittedPolicy = changed({ ...clonePolicy(), emitted: { ...clonePolicy().emitted, commonJsInitializerIdentifiers: [] } }); + for (const policy of [buildPolicy, pathPolicy, sourcePolicy, specifierPolicy, metafilePolicy, emittedPolicy]) { + assert.notEqual(identity(DYNAMICS_STATIC_CLOSURE_POLICY), identity(policy)); + } + assert.throws(() => staticPreserveSymlinks(buildPolicy), /symlink preservation/u); + await assert.rejects(validateStaticSourcePath(path.join(root, "source.mjs"), root, pathPolicy), /leaf path policy/u); + assert.throws(() => assertStaticSource("source.mjs", "require('node:crypto');", sourcePolicy), /require/u); + assert.throws(() => classifyStaticModuleSpecifier("node:crypto", specifierPolicy), /unapproved/u); + assert.deepEqual(auditStaticMetafileImports([{ external: false, path: "node:crypto" }], metafilePolicy), ["node:crypto"]); + assert.throws(() => assertStaticEmittedEsm("out.mjs", "const x = __commonJS(() => {});", emittedPolicy), /unrecognized/u); + } finally { await rm(root, { recursive: true, force: true }); } + }); + + it("requires a regular contained path with no symlinked component and closed modes", async () => { + const root = await mkdtemp(path.join(os.tmpdir(), "simfile-static-path-")); + const outside = await mkdtemp(path.join(os.tmpdir(), "simfile-static-outside-")); + try { + await mkdir(path.join(root, "nested")); + await writeFile(path.join(root, "nested", "source.mjs"), "export {};\n"); + await writeFile(path.join(outside, "outside.mjs"), "export {};\n"); + assert.equal(await validateStaticSourcePath(path.join(root, "nested", "source.mjs"), root), path.join(root, "nested", "source.mjs")); + await assert.rejects(validateStaticSourcePath(path.join(outside, "outside.mjs"), root)); + await assert.rejects(validateStaticSourcePath(path.join(root, "missing.mjs"), root)); + await assert.rejects(validateStaticSourcePath(path.join(root, "nested"), root)); + await symlink(outside, path.join(root, "link-boundary")); + await assert.rejects(validateStaticSourcePath(path.join(root, "nested", "source.mjs"), path.join(root, "link-boundary"))); + await symlink(path.join(root, "nested"), path.join(root, "linked-nested")); + await assert.rejects(validateStaticSourcePath(path.join(root, "linked-nested", "source.mjs"), root)); + await symlink(path.join(root, "nested", "source.mjs"), path.join(root, "leaf.mjs")); + await assert.rejects(validateStaticSourcePath(path.join(root, "leaf.mjs"), root)); + assert.equal((await lstat(path.join(root, "leaf.mjs"))).isSymbolicLink(), true); + for (const pathPolicy of [ + changed({ ...clonePolicy(), path: { ...clonePolicy().path, boundary: "other" } }), + changed({ ...clonePolicy(), path: { ...clonePolicy().path, traversedDirectories: "other" } }), + changed({ ...clonePolicy(), path: { ...clonePolicy().path, lexicalContainment: "other" } }) + ]) await assert.rejects(validateStaticSourcePath(path.join(root, "nested", "source.mjs"), root, pathPolicy)); + } finally { await rm(root, { recursive: true, force: true }); await rm(outside, { recursive: true, force: true }); } + }); + + it("accepts static source forms and rejects require capability and specifier escapes", () => { + assert.doesNotThrow(() => assertStaticSource("source.ts", "import x from './x.mjs'; export { x } from './y.mjs'; import('./z.mjs'); require('pkg');")); + assert.doesNotThrow(() => assertStaticSource("source.mjs", "import('./local.mjs');")); + assert.throws(() => assertStaticSource("source.mjs", "import(\'./local.mjs\', { with: { type: \'json\' } });"), /static source/u); + assert.doesNotThrow(() => assertStaticSource("source.mjs", "const target = { load(x: unknown) { return x; } }; target['load'](1); target.load(1);")); + assert.doesNotThrow(() => assertStaticSource("source.mjs", "const target = { load(x: unknown) { return x; } }; target[\"lo\" + \"ad\"](1);")); + for (const source of [ + "import(x);", "require(x);", "(require)(x);", "((require))(x);", "(0, require)(x);", "require.call(null, x);", "const r = require; r(x);", + "globalThis['require'](x);", "globalThis.require(x);", "globalThis['require']?.(x);", "globalThis.require?.(x);", "module['require']?.(x);", + "module?.require(x);", "require?.(x);", "require['call'](x);", "globalThis['createRequire']('file:///.');", + "const r = createRequire; r(x);", "import('pkg');", "import('node:fs');", "import('/x.mjs');", + "import('https://example.test/x.mjs');", "import('./' + 'local.mjs');" + ]) assert.throws(() => assertStaticSource("source.mjs", source), /static source/u); + for (const source of ["import 'https://example.test/x.mjs';", "import '/x.mjs';", "import 'C:\\\\x.mjs';", "import 'crypto';", "import '_http_agent';", "import 'node:fs';"]) { + assert.throws(() => assertStaticSource("source.mjs", source), /static source/u); + } + assert.equal(classifyStaticModuleSpecifier("./x.mjs"), "relative"); + assert.equal(classifyStaticModuleSpecifier("pkg"), "package"); + assert.equal(classifyStaticModuleSpecifier("node:crypto"), "node-builtin"); + const changedDrive = changed({ ...clonePolicy(), specifier: { ...clonePolicy().specifier, windowsDriveAbsolutePattern: "^Z:" } }); + const changedUrl = changed({ ...clonePolicy(), specifier: { ...clonePolicy().specifier, urlSchemePattern: "^z:" } }); + assert.equal(classifyStaticModuleSpecifier("C:\\x.mjs", changedDrive), "package"); + assert.equal(classifyStaticModuleSpecifier("https://example.test/x.mjs", changedUrl), "package"); + }); + + it("handles import-equals and fails closed on extension-family changes", () => { + assert.doesNotThrow(() => assertStaticSource("source.ts", "import local = require('./local'); export = local;")); + for (const source of ["import fs = require('fs');", "import fs = require('node:fs');", "import bad = require('https://example.test/x');"]) { + assert.throws(() => assertStaticSource("source.ts", source), /static source/u); + } + const noTs = changed({ ...clonePolicy(), source: { ...clonePolicy().source, typeScriptExtensions: [] } }); + const noMjs = changed({ ...clonePolicy(), source: { ...clonePolicy().source, javaScriptExtensions: [".js", ".cjs"] } }); + assert.throws(() => assertStaticSource("source.ts", "export {};", noTs), /unsupported extension/u); + assert.throws(() => assertStaticSource("source.mjs", "export {};", noMjs), /unsupported extension/u); + assert.throws(() => assertStaticSource("source.unknown", "export {};"), /unsupported extension/u); + }); + + it("rejects unsupported graph filenames before attempting a source read", async () => { + let reads = 0; + await assert.rejects(preflightStaticGraph(["/fixture/extensionless"], DYNAMICS_STATIC_CLOSURE_POLICY, { + edge: () => undefined, + inspect: () => undefined, + read: async () => { reads += 1; return "export const value = 1;"; }, + resolve: () => undefined, + validate: async (fileName) => ({ fileName }) + }), /unsupported extension/u); + assert.equal(reads, 0); + }); + + it("classifies erased edges as type-only while retaining mixed value reachability", () => { + const edges = staticSourceSpecifiers("fixture.ts", [ + "/// ", + "/// ", + "import type { Type } from './types.ts';", + "import Default, { type Type } from './default-mixed.ts';", + "export type { Type } from './types.ts';", + "import { type Type, value } from './mixed.ts';", + "type FromImport = import('./imported.ts').Value;" + ].join("\n"), DYNAMICS_STATIC_CLOSURE_POLICY); + assert.deepEqual(edges.map((edge) => [edge.specifier, edge.mode]), [ + ["./types.ts", "type-only"], ["./default-mixed.ts", "runtime"], ["./types.ts", "type-only"], ["./mixed.ts", "runtime"], + ["./imported.ts", "type-only"], ["node", "type-only"], ["./path.d.ts", "type-only"] + ]); + }); + + it("reports both missing and extra immutable runtime preflight evidence", () => { + assert.throws(() => assertExactRuntimeInputs(new Set(["/expected.mjs"]), ["/observed.mjs"]), /missing: \/expected\.mjs; extra: \/observed\.mjs/u); + }); + + it("rejects unsafe path references before resolving or reading their targets", async () => { + for (const reference of ["/absolute.d.ts", "C:\\\\absolute.d.ts", "https://example.test/types.d.ts"]) { + let reads = 0; + let resolves = 0; + await assert.rejects(preflightStaticGraph(["/fixture/source.ts"], DYNAMICS_STATIC_CLOSURE_POLICY, { + edge: () => undefined, + inspect: () => undefined, + read: async () => { reads += 1; return `/// \nexport {};`; }, + resolve: () => { resolves += 1; return "/fixture/target.d.ts"; }, + validate: async (fileName) => ({ fileName }) + }), /unsafe path reference/u); + assert.equal(reads, 1); + assert.equal(resolves, 0); + } + }); + + it("uses only policy-derived approved builtins and UTF-16 metafile ordering", () => { + const sortingPolicy = changed({ ...clonePolicy(), specifier: { ...clonePolicy().specifier, approvedNodeBuiltins: ["node:z", "node:crypto", "node:a"] } }); + const used = auditStaticMetafileImports([ + { external: true, path: "node:crypto" }, { external: false, path: "./bundled.mjs" }, { external: true, path: "node:z" }, { external: true, path: "node:a" }, { external: true, path: "node:crypto" } + ], sortingPolicy); + assert.deepEqual(used, ["node:a", "node:crypto", "node:z"]); + assert.throws(() => auditStaticMetafileImports([{ external: true, path: "pkg" }])); + assert.throws(() => auditStaticMetafileImports([{ external: true, path: "node:fs" }])); + const noBuiltins = changed({ ...clonePolicy(), specifier: { ...clonePolicy().specifier, approvedNodeBuiltins: [] } }); + assert.throws(() => auditStaticMetafileImports([{ external: true, path: "node:crypto" }], noBuiltins), /unapproved/u); + const unsupportedOrdering = changed({ ...clonePolicy(), metafile: { ...clonePolicy().metafile, ordering: "other" } }); + assert.throws(() => auditStaticMetafileImports([], unsupportedOrdering), /ordering/u); + }); + + it("seals emitted ESM while allowing only exact closed CommonJS initialization", async () => { + const pinned = await buildClosedCommonJsOutput(); + const callOnly = pinned.slice(pinned.indexOf("var require_")); + assert.throws(() => assertStaticEmittedEsm("out.mjs", callOnly), /emitted ESM/u); + assert.doesNotThrow(() => assertStaticEmittedEsm("out.mjs", "import { createHash } from 'node:crypto'; export { createHash };")); + assert.doesNotThrow(() => assertStaticEmittedEsm("out.mjs", pinned)); + const commonJsMutations = [ + ["helper declaration let", "var __commonJS", "let __commonJS"], + ["helper declaration const", "var __commonJS", "const __commonJS"], + ["helper declaration extra declarator", "var __commonJS", "var extra = 0, __commonJS"], + ["helper declaration export", "var __commonJS", "export var __commonJS"], + ["get-own declaration export", "var __getOwnPropNames", "export var __getOwnPropNames"], + ["get-own duplicate declaration", "var __commonJS", "var __getOwnPropNames = Object.getOwnPropertyNames;\nvar __commonJS"], + ["get-own invalid declaration", "var __commonJS", "var __getOwnPropNames = Object.keys;\nvar __commonJS"], + ["get-own later assignment", "var __commonJS", "__getOwnPropNames = Object.keys;\nvar __commonJS"], + ["get-own export", "var __commonJS", "export { __getOwnPropNames };\nvar __commonJS"], + ["get-own extra call", "var __commonJS", "__getOwnPropNames({});\nvar __commonJS"], + ["outer helper cb default", "(cb, mod) =>", "(cb = {}, mod) =>"], + ["outer helper mod default", "(cb, mod) =>", "(cb, mod = 0) =>"], + ["initializer factory exports default", "(exports, module)", "(exports = {}, module)"], + ["outer helper async modifier", "var __commonJS = (cb, mod) =>", "var __commonJS = async (cb, mod) =>"], + ["named require generator", "function __require()", "function* __require()"], + ["try finally block", " }\n};", " } finally {\n }\n};"], + ["optional get-own-property helper", "Object.getOwnPropertyNames", "Object?.getOwnPropertyNames"], + ["computed get-own-property helper", "Object.getOwnPropertyNames", "Object[\"getOwnPropertyNames\"]"], + ["optional resolver array lookup", "cb[__getOwnPropNames(cb)[0]]", "cb?.[__getOwnPropNames(cb)[0]]"], + ["__copyProps __getOwnPropNames(from) -> __getOwnPropNames?.(from)", "__getOwnPropNames(from)", "__getOwnPropNames?.(from)"], + ["__esm __getOwnPropNames(fn) -> __getOwnPropNames?.(fn)", "__getOwnPropNames(fn)", "__getOwnPropNames?.(fn)"], + ["__esm function __init() -> function* __init()", "function __init()", "function* __init()"], + ["__esm function __init() -> async function __init()", "function __init()", "async function __init()"] + ] as const; + for (const [reason, expected, replacement] of commonJsMutations) { + assert.throws( + () => assertStaticEmittedEsm("out.mjs", replacePinned(pinned, expected, replacement)), + /emitted ESM/u, + reason + ); + } + const helper = pinned.slice(pinned.indexOf("var __commonJS"), pinned.indexOf("\n\n//")); + assert.throws(() => assertStaticEmittedEsm("out.mjs", `{\n${pinned}\n}`), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", `${pinned}\n${helper}`), /emitted ESM/u); + for (const output of [ + "import './left-over.mjs';", "export * from 'pkg';", "import(x);", "import('node:crypto');", "const x = require('pkg');", "const x = __require('pkg');", + "const x = createRequire(import.meta.url);", "const x = __commonJSRequire(() => {});", + "const x = before__commonJS(() => {});", "const x = __commonJS2(() => {});", "const x = globalThis['require']('pkg');", "const x = module.require('pkg');", + "const x = globalThis.require?.('pkg');", "const x = module['require']?.('pkg');" + ]) assert.throws(() => assertStaticEmittedEsm("out.mjs", output), /emitted ESM/u); + const noInitializer = changed({ ...clonePolicy(), emitted: { ...clonePolicy().emitted, commonJsInitializerIdentifiers: [] } }); + assert.throws(() => assertStaticEmittedEsm("out.mjs", callOnly, noInitializer), /unrecognized/u); + const closedCopied = changed({ + ...clonePolicy(), + emitted: { + ...clonePolicy().emitted, + commonJsInitializerIdentifiers: ["__closedCJS"] + } + }); + assert.doesNotThrow(() => assertStaticEmittedEsm("out.mjs", pinned.replaceAll("__commonJS", "__closedCJS"), closedCopied)); + for (const output of [ + "var x = __commonJS(() => {});", + "var x = __commonJSRequire(() => {});", + "var x = __commonJS2(() => {});", + "var x = __closedCJSRequire(() => {});" + ]) { + assert.throws(() => assertStaticEmittedEsm("out.mjs", output, closedCopied), /emitted ESM/u); + } + const reservedCopied = changed({ + ...clonePolicy(), + emitted: { + ...clonePolicy().emitted, + commonJsInitializerIdentifiers: ["__closedCJS"], + commonJsReservedIdentifierPattern: "__retiredCJS" + } + }); + assert.throws(() => assertStaticEmittedEsm("out.mjs", "var x = __retiredCJS2(() => {});", reservedCopied), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", "var x = __commonJS;"), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", "const helper = __commonJS; var x = helper({ 'x.js'(exports, module) { exports.value = 1; } });"), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", "var x = __commonJS(123);"), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", "var x = __commonJS?.({ 'x.js'(exports, module) { exports.value = 1; } });"), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", `${pinned}\nvar require_x = __commonJS?.({ 'x.js'(exports, module) { exports.value = 1; } });`), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", `${pinned.replace("mod.exports", "mod")}\nvar require_x = __commonJS({ 'x.js'(exports, module) { exports.value = 1; } });`), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", `${pinned.replace(", mod.exports", "")}\nvar require_x = __commonJS({ 'x.js'(exports, module) { exports.value = 1; } });`), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", `${pinned.replace("}).exports, mod", "}).exports")}\nvar require_x = __commonJS({ 'x.js'(exports, module) { exports.value = 1; } });`), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", "var x = globalThis.__commonJS({ 'x.js'(exports, module) { exports.value = 1; } });"), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", "var x = __commonJS({ 'x.js'(exports, module) { exports.value = 1; } });"), /emitted ESM/u); + assert.throws(() => assertStaticEmittedEsm("out.mjs", pinned.replace(/var __getOwnPropNames[\s\S]*?var __commonJS/u, "var __commonJS")), /emitted ESM/u); + const resolverCopy = changed({ ...clonePolicy(), emitted: { ...clonePolicy().emitted, resolverIdentifiers: [...clonePolicy().emitted.resolverIdentifiers, "globalThis"] } }); + assert.throws(() => assertStaticEmittedEsm("out.mjs", "const x = globalThis.createRequire('pkg');", resolverCopy), /host resolver/u); + const noDynamicImport = changed({ ...clonePolicy(), emitted: { ...clonePolicy().emitted, dynamicImport: "other" } }); + assert.throws(() => assertStaticEmittedEsm("out.mjs", "export {};", noDynamicImport), /dynamic import policy/u); + }); + + it("parses only marker-bearing authored text without evaluating it", () => { + const marker = "__SIMFILE_STATIC_POLICY_MARKER__"; + const source = `globalThis[${JSON.stringify(marker)}] = true; export const value = 1;`; + delete (globalThis as Record)[marker]; + assertStaticSource("source.mjs", source); + assert.equal((globalThis as Record)[marker], undefined); + }); +}); diff --git a/src/dynamics/buildStaticPolicy.ts b/src/dynamics/buildStaticPolicy.ts new file mode 100644 index 0000000..4885cf9 --- /dev/null +++ b/src/dynamics/buildStaticPolicy.ts @@ -0,0 +1,386 @@ +import { lstat } from "node:fs/promises"; +import { builtinModules } from "node:module"; +import path from "node:path"; +import ts from "typescript"; + +import { compareUtf16, deepFreeze } from "./buildIdentity.js"; +import { DYNAMICS_BUILD_PREPARATION_POLICY } from "./buildInput.js"; +import { + collectPinnedCommonJsInitializerIdentifiers, + isCommonJsCandidateIdentifier, + isCommonJsInitializerArgument, + isExactCommonJsInitializerCall +} from "./buildStaticCommonJsPolicy.js"; +import { + collectStaticResolverAliases, + isStaticCallOnResolver, + isStaticResolverIdentifier, + isStaticResolverProperty, + staticLiteralSpecifierValue, + staticMemberName, + staticSourceFile, + staticWrappedExpressionCarrier, + unwrapStaticCallExpression, + unwrapStaticExpression +} from "./buildStaticResolverPolicy.js"; + +export interface StaticMetafileImport { + readonly external?: boolean; + readonly path: string; +} + +export type StaticModuleSpecifierKind = "node-builtin" | "package" | "relative"; + +export interface StaticClosurePolicy { + readonly build: Readonly<{ + preserveSymlinks: true; + }>; + readonly emitted: Readonly<{ + commonJsInitializerIdentifiers: readonly string[]; + commonJsReservedIdentifierPattern: string; + dynamicImport: "reject"; + dynamicRequireIdentifierPrefixes: readonly string[]; + resolverIdentifiers: readonly string[]; + }>; + readonly metafile: Readonly<{ + externalFlag: boolean; + ordering: "utf16"; + }>; + readonly path: Readonly<{ + boundary: "regular-non-symlink-directory"; + leaf: "regular-non-symlink-file"; + lexicalContainment: "contained"; + traversedDirectories: "regular-non-symlink-directory"; + }>; + readonly source: Readonly<{ + expressionUnwrap: readonly string[]; + javaScriptExtensions: readonly string[]; + literalSpecifierKinds: readonly string[]; + lookupForms: readonly string[]; + requireIdentifier: string; + scriptKinds: readonly string[]; + scriptTarget: "ES2022"; + typeScriptExtensions: readonly string[]; + }>; + readonly specifier: Readonly<{ + absolutePrefixes: readonly string[]; + approvedNodeBuiltins: readonly string[]; + bareBuiltinNames: readonly string[]; + nodeBuiltinPrefix: "node:"; + relativePrefixes: readonly string[]; + urlSchemePattern: string; + windowsDriveAbsolutePattern: string; + }>; +} + +const has = (values: readonly string[], value: string): boolean => values.includes(value); + +const LEGACY_BARE_NODE_BUILTINS = builtinModules.filter((name) => !name.startsWith("node:")); +const SIMFILE_DYNAMICS_MODULE_SPECIFIER = DYNAMICS_BUILD_PREPARATION_POLICY.simfileDynamics.moduleSpecifier; +const isSimfileDynamicsSpecifier = (specifier: string): boolean => + specifier === SIMFILE_DYNAMICS_MODULE_SPECIFIER; + +export const DYNAMICS_STATIC_CLOSURE_POLICY: Readonly = deepFreeze({ + build: { + preserveSymlinks: true, + }, + emitted: { + commonJsInitializerIdentifiers: ["__commonJS"], + commonJsReservedIdentifierPattern: "__commonJS", + dynamicImport: "reject", + dynamicRequireIdentifierPrefixes: ["__require"], + resolverIdentifiers: ["require", "createRequire"], + }, + metafile: { + externalFlag: true, + ordering: "utf16", + }, + path: { + boundary: "regular-non-symlink-directory", + leaf: "regular-non-symlink-file", + lexicalContainment: "contained", + traversedDirectories: "regular-non-symlink-directory", + }, + source: { + expressionUnwrap: ["parenthesized", "as-expression", "type-assertion", "non-null"], + javaScriptExtensions: [".js", ".mjs", ".cjs"], + literalSpecifierKinds: ["string-literal", "no-substitution-template"], + lookupForms: ["static-import", "static-export", "dynamic-import", "require-direct"], + requireIdentifier: "require", + scriptKinds: ["js", "ts"], + scriptTarget: "ES2022", + typeScriptExtensions: [".ts", ".tsx", ".mts", ".cts"], + }, + specifier: { + absolutePrefixes: ["/", "\\"], + approvedNodeBuiltins: [...DYNAMICS_BUILD_PREPARATION_POLICY.nodeBuiltins], + bareBuiltinNames: LEGACY_BARE_NODE_BUILTINS, + nodeBuiltinPrefix: "node:", + relativePrefixes: ["./", "../"], + urlSchemePattern: "^(?![A-Za-z]:[\\\\/])[A-Za-z][A-Za-z0-9+.-]*:", + windowsDriveAbsolutePattern: "^[A-Za-z]:[\\\\/]", + }, +}); + +export const staticPreserveSymlinks = ( + policy: StaticClosurePolicy = DYNAMICS_STATIC_CLOSURE_POLICY +): true => { + if (policy.build.preserveSymlinks !== true) throw new Error("static source has an unsupported symlink preservation policy"); + return true; +}; + +const assertRegularDirectory = async (candidate: string, rule: string): Promise => { + if (rule !== "regular-non-symlink-directory") throw new Error("static source has an unsupported directory path policy"); + const stat = await lstat(candidate); + if (stat.isSymbolicLink() || !stat.isDirectory()) throw new Error(`static source path is not regular: ${candidate}`); +}; + +export const validateStaticSourcePath = async ( + candidate: string, + boundary: string, + policy: StaticClosurePolicy = DYNAMICS_STATIC_CLOSURE_POLICY +): Promise => { + if (policy.path.lexicalContainment !== "contained") throw new Error("static source has an unsupported containment path policy"); + const root = path.resolve(boundary); + const fileName = path.resolve(candidate); + const relative = path.relative(root, fileName); + if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`static source path is outside its boundary: ${candidate}`); + } + await assertRegularDirectory(root, policy.path.boundary); + const parts = relative.split(path.sep); + for (let index = 0; index < parts.length - 1; index += 1) { + await assertRegularDirectory(path.join(root, ...parts.slice(0, index + 1)), policy.path.traversedDirectories); + } + if (policy.path.leaf !== "regular-non-symlink-file") throw new Error("static source has an unsupported leaf path policy"); + const leaf = await lstat(fileName); + if (leaf.isSymbolicLink() || !leaf.isFile()) throw new Error(`static source path leaf is not a regular file: ${candidate}`); + return fileName; +}; + +/** Validates a trusted-anchor-contained directory before package resolution reads it. */ +export const validateStaticDirectoryPath = async ( + candidate: string, + boundary: string, + policy: StaticClosurePolicy = DYNAMICS_STATIC_CLOSURE_POLICY +): Promise => { + const root = path.resolve(boundary); + const directory = path.resolve(candidate); + const relative = path.relative(root, directory); + if (relative === "" || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new Error(`static source directory is outside its boundary: ${candidate}`); + } + await assertRegularDirectory(root, policy.path.boundary); + const parts = relative.split(path.sep); + for (let index = 0; index < parts.length; index += 1) { + await assertRegularDirectory(path.join(root, ...parts.slice(0, index + 1)), policy.path.traversedDirectories); + } + return directory; +}; + +export const classifyStaticModuleSpecifier = ( + specifier: string, + policy: StaticClosurePolicy = DYNAMICS_STATIC_CLOSURE_POLICY +): StaticModuleSpecifierKind => { + const rules = policy.specifier; + if (specifier.startsWith(rules.nodeBuiltinPrefix)) { + if (!rules.approvedNodeBuiltins.includes(specifier)) throw new Error(`static source uses an unapproved node builtin: ${specifier}`); + return "node-builtin"; + } + if (rules.absolutePrefixes.some((prefix) => specifier.startsWith(prefix)) || new RegExp(rules.windowsDriveAbsolutePattern, "u").test(specifier)) { + throw new Error(`static source uses an absolute module specifier: ${specifier}`); + } + if (new RegExp(rules.urlSchemePattern, "u").test(specifier)) throw new Error(`static source uses a URL module specifier: ${specifier}`); + if (rules.bareBuiltinNames.includes(specifier)) throw new Error(`static source uses a legacy node builtin spelling: ${specifier}`); + return rules.relativePrefixes.some((prefix) => specifier.startsWith(prefix)) ? "relative" : "package"; +}; + +/** Path references are filesystem paths, so bare names remain importer-relative. */ +export const assertStaticPathReference = ( + specifier: string, + policy: StaticClosurePolicy = DYNAMICS_STATIC_CLOSURE_POLICY +): void => { + const rules = policy.specifier; + if (specifier.length === 0 || specifier.includes("\0") + || rules.absolutePrefixes.some((prefix) => specifier.startsWith(prefix)) + || new RegExp(rules.windowsDriveAbsolutePattern, "u").test(specifier) + || new RegExp(rules.urlSchemePattern, "u").test(specifier)) { + throw new Error(`static source has an unsafe path reference: ${specifier}`); + } +}; + +const assertLiteral = (expression: ts.Expression, policy: StaticClosurePolicy): void => { + const specifier = staticLiteralSpecifierValue(expression, policy); + if (specifier === undefined) throw new Error("static source has a nonliteral module lookup"); + classifyStaticModuleSpecifier(specifier, policy); +}; + +const isDirectResolverCallOnSimfileModule = ( + node: ts.CallExpression, + policy: StaticClosurePolicy, + aliases: ReadonlySet +): boolean => { + const callee = unwrapStaticCallExpression(node.expression, policy); + if (ts.isIdentifier(callee) && (isStaticResolverIdentifier(callee, policy) || aliases.has(callee.text))) { + if (node.arguments.length === 0) return false; + const specifier = staticLiteralSpecifierValue(node.arguments[0], policy); + return specifier !== undefined && isSimfileDynamicsSpecifier(specifier); + } + if (ts.isPropertyAccessExpression(callee) || ts.isElementAccessExpression(callee)) { + const calledProperty = staticMemberName(callee, policy); + if (calledProperty !== "call") return false; + if (node.arguments.length < 2) return false; + const calledReceiver = unwrapStaticExpression(callee.expression, policy); + if (ts.isIdentifier(calledReceiver) && (isStaticResolverIdentifier(calledReceiver, policy) || aliases.has(calledReceiver.text))) { + const specifier = staticLiteralSpecifierValue(node.arguments[1], policy); + return specifier !== undefined && isSimfileDynamicsSpecifier(specifier); + } + } + return false; +}; + +export const assertStaticSource = ( + fileName: string, + text: string, + policy: StaticClosurePolicy = DYNAMICS_STATIC_CLOSURE_POLICY +): void => { + const parsed = staticSourceFile(fileName, text, policy); + const resolverAliases = new Set(); + collectStaticResolverAliases(parsed, policy, resolverAliases); + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node)) { + if (!has(policy.source.lookupForms, "static-import")) throw new Error("static source has an unsupported import declaration"); + assertLiteral(node.moduleSpecifier, policy); + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier) { + if (!has(policy.source.lookupForms, "static-export")) throw new Error("static source has an unsupported export declaration"); + assertLiteral(node.moduleSpecifier, policy); + } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) { + if (!has(policy.source.lookupForms, "static-import") || !node.moduleReference.expression) { + throw new Error("static source has an unsupported import-equals declaration"); + } + assertLiteral(node.moduleReference.expression, policy); + } + if (ts.isCallExpression(node)) { + if (node.expression.kind === ts.SyntaxKind.ImportKeyword) { + if (!has(policy.source.lookupForms, "dynamic-import") || node.arguments.length !== 1) { + throw new Error("static source has an invalid import lookup"); + } + const specifier = staticLiteralSpecifierValue(node.arguments[0], policy); + if (specifier === undefined) throw new Error("static source has a nonliteral dynamic module lookup"); + if (isSimfileDynamicsSpecifier(specifier)) return; + if (classifyStaticModuleSpecifier(specifier, policy) !== "relative") { + throw new Error("static source has a non-relative dynamic module lookup"); + } + } else if (!node.questionDotToken && isStaticResolverIdentifier(node.expression, policy) && has(policy.source.lookupForms, "require-direct")) { + if (node.arguments.length !== 1) throw new Error("static source has an invalid require lookup"); + if (!isDirectResolverCallOnSimfileModule(node, policy, resolverAliases)) { + assertLiteral(node.arguments[0], policy); + } + } else if (isStaticCallOnResolver(node.expression, policy, resolverAliases)) { + if (isDirectResolverCallOnSimfileModule(node, policy, resolverAliases)) return; + throw new Error("static source lets require escape through a property lookup"); + } + } + if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && isStaticResolverProperty(node, policy)) { + throw new Error("static source lets require escape through a property lookup"); + } + if (ts.isIdentifier(node) && isStaticResolverIdentifier(node, policy) && !resolverAliases.has(node.text)) { + const carrier = staticWrappedExpressionCarrier(node); + if (ts.isCallExpression(carrier.parent) && carrier.parent.expression === carrier) return; + if ((ts.isPropertyAccessExpression(carrier.parent) || ts.isElementAccessExpression(carrier.parent)) + && ts.isCallExpression(carrier.parent.parent) + && carrier.parent.parent.expression === carrier.parent + && carrier.parent.parent.arguments.length >= 2 + && staticMemberName(carrier.parent, policy) === "call") { + const argument = carrier.parent.parent.arguments[1]; + if (typeof argument !== "undefined" && staticLiteralSpecifierValue(argument, policy) === SIMFILE_DYNAMICS_MODULE_SPECIFIER) return; + } + throw new Error("static source lets require escape direct literal lookup"); + } + ts.forEachChild(node, visit); + }; + visit(parsed); +}; + +export const assertStaticOwnershipEdge = ( + importerBoundary: string, + targetBoundary: string, + kind: StaticModuleSpecifierKind, + original: string +): void => { + if (kind === "relative" && importerBoundary !== targetBoundary) { + throw new Error(`dynamics build lets a relative import escape its boundary: ${original}`); + } +}; + +export const auditStaticMetafileImports = ( + imports: readonly StaticMetafileImport[], + policy: StaticClosurePolicy = DYNAMICS_STATIC_CLOSURE_POLICY +): readonly string[] => { + if (policy.metafile.ordering !== "utf16") throw new Error("static output has an unsupported metafile ordering"); + const used = new Set(); + for (const item of imports) { + if (item.external !== policy.metafile.externalFlag) continue; + const kind = classifyStaticModuleSpecifier(item.path, policy); + if (kind !== "node-builtin") throw new Error(`static output leaves an unapproved external: ${item.path}`); + used.add(item.path); + } + return [...used].sort(compareUtf16); +}; + +const assertEmittedPolicy = (policy: StaticClosurePolicy): void => { + if (policy.emitted.dynamicImport !== "reject") throw new Error("emitted ESM has an unsupported dynamic import policy"); + if (policy.emitted.commonJsInitializerIdentifiers.some((identifier) => identifier.length === 0) + || policy.emitted.commonJsReservedIdentifierPattern.length === 0) throw new Error("emitted ESM has an unsupported CommonJS policy"); +}; + +export const assertStaticEmittedEsm = ( + fileName: string, + text: string, + policy: StaticClosurePolicy = DYNAMICS_STATIC_CLOSURE_POLICY +): void => { + assertEmittedPolicy(policy); + const parsed = staticSourceFile(fileName, text, policy); + const resolverAliases = new Set(); + const pinnedCommonJs = collectPinnedCommonJsInitializerIdentifiers(parsed, policy); + collectStaticResolverAliases(parsed, policy, resolverAliases); + const visit = (node: ts.Node): void => { + if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && node.moduleSpecifier) { + if (!ts.isStringLiteralLike(node.moduleSpecifier)) throw new Error("emitted ESM has a nonliteral import"); + if (classifyStaticModuleSpecifier(node.moduleSpecifier.text, policy) !== "node-builtin") { + throw new Error(`emitted ESM leaves a residual module lookup: ${node.moduleSpecifier.text}`); + } + } + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + if (policy.emitted.dynamicImport === "reject") throw new Error("emitted ESM has a dynamic import"); + } else if (ts.isCallExpression(node) && node.arguments.length === 1 && isCommonJsInitializerArgument(node.arguments[0])) { + if (!isExactCommonJsInitializerCall(node, policy, pinnedCommonJs.declarations)) { + throw new Error("emitted ESM has an unrecognized CommonJS initializer"); + } + } else if (ts.isCallExpression(node) && isStaticCallOnResolver(node.expression, policy, resolverAliases)) { + throw new Error("emitted ESM retains a host resolver"); + } + if (ts.isIdentifier(node)) { + const inPinnedCommonJsDeclaration = pinnedCommonJs.declarations.has(node); + if (node.text === "__getOwnPropNames" && !pinnedCommonJs.getOwnPropNames.has(node)) { + throw new Error("emitted ESM has an unrecognized CommonJS helper binding"); + } + if (isStaticResolverIdentifier(node, policy)) throw new Error(`emitted ESM retains a host resolver: ${node.text}`); + if (isCommonJsCandidateIdentifier(node.text, policy) && !inPinnedCommonJsDeclaration && !( + ts.isCallExpression(node.parent) + && node.parent.expression === node + && isExactCommonJsInitializerCall(node.parent, policy, pinnedCommonJs.declarations) + )) { + throw new Error(`emitted ESM has an unrecognized CommonJS initializer: ${node.text}`); + } + if (policy.emitted.dynamicRequireIdentifierPrefixes.some((prefix) => node.text.startsWith(prefix)) && !pinnedCommonJs.requireDefinitions.has(node)) { + throw new Error(`emitted ESM retains a host resolver: ${node.text}`); + } + } + if ((ts.isPropertyAccessExpression(node) || ts.isElementAccessExpression(node)) && isStaticResolverProperty(node, policy)) { + throw new Error("emitted ESM retains a host resolver property"); + } + ts.forEachChild(node, visit); + }; + visit(parsed); +}; diff --git a/src/dynamics/buildStaticResolverPolicy.ts b/src/dynamics/buildStaticResolverPolicy.ts new file mode 100644 index 0000000..14be89e --- /dev/null +++ b/src/dynamics/buildStaticResolverPolicy.ts @@ -0,0 +1,323 @@ +import ts from "typescript"; +import path from "node:path"; +import { readFile } from "node:fs/promises"; + +import { DYNAMICS_BUILD_PREPARATION_POLICY } from "./buildInput.js"; +import { + enclosingPackageFor, + isContained, + nodeModulesPackageFor, + type PackageIdentity +} from "./buildPackagePolicy.js"; +import type { DynamicsBuildSourceSnapshot } from "./buildSourceSnapshot.js"; +import { + DYNAMICS_STATIC_CLOSURE_POLICY, + assertStaticPathReference, + validateStaticDirectoryPath, + validateStaticSourcePath, + type StaticClosurePolicy +} from "./buildStaticPolicy.js"; + +export interface StaticSourceSpecifier { + readonly kind: "module" | "path-reference" | "type-reference"; + readonly mode: "runtime" | "type-only"; + readonly specifier: string; +} +export interface StaticRuntimeSourceBinding { + readonly boundary: string; + readonly fileName: string; + readonly identity?: PackageIdentity; + readonly kind: "package" | "project"; +} + +export interface StaticGraphAdapter { + readonly edge: (importer: T, target: T, specifier: StaticSourceSpecifier) => Promise | void; + readonly inspect: (source: T, text: string) => Promise | void; + readonly read: (fileName: string) => Promise; + readonly resolve: (specifier: StaticSourceSpecifier, importer: T) => Promise | string | undefined; + readonly validate: (fileName: string) => Promise; +} + +const staticTypeScriptPolicy = DYNAMICS_BUILD_PREPARATION_POLICY.typescript; + +export const staticResolutionOptions: ts.CompilerOptions = { + allowImportingTsExtensions: staticTypeScriptPolicy.compilerOptions.allowImportingTsExtensions, + allowJs: staticTypeScriptPolicy.allowJs, + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + target: ts.ScriptTarget.ES2022 +}; + +/** Resolves executable package bytes without substituting declaration packages. */ +export const staticRuntimeResolutionOptions: ts.CompilerOptions = { + ...staticResolutionOptions, + noDtsResolution: staticTypeScriptPolicy.runtimeNoDtsResolution +}; + +/** TypeScript may read approved package files, but must not erase their lexical path. */ +export const staticResolutionHost: ts.ModuleResolutionHost = { ...ts.sys, realpath: undefined }; + +export const staticSharedPathAnchor = (left: string, right: string): string => { + const leftParts = path.resolve(left).split(path.sep); + const rightParts = path.resolve(right).split(path.sep); + const parts: string[] = []; + for (let index = 0; index < Math.min(leftParts.length, rightParts.length) && leftParts[index] === rightParts[index]; index += 1) { + parts.push(leftParts[index] as string); + } + return parts.length > 1 ? path.resolve(path.parse(left).root, ...parts.slice(1)) : path.parse(left).root; +}; + +/** Resolves and validates one executable source against its lexical owner. */ +export const validateStaticRuntimeSource = async ( + fileName: string, + projectRoot: string, + snapshot: DynamicsBuildSourceSnapshot +): Promise => { + const candidate = path.resolve(fileName); + await validateStaticSourcePath( + candidate, + staticSharedPathAnchor(projectRoot, candidate), + DYNAMICS_STATIC_CLOSURE_POLICY + ); + const localPackage = await nodeModulesPackageFor(candidate, snapshot.readBytes); + const source = localPackage + ? { + boundary: localPackage.directory, + fileName: candidate, + identity: localPackage, + kind: "package" as const + } + : isContained(projectRoot, candidate) + ? { boundary: projectRoot, fileName: candidate, kind: "project" as const } + : await (async () => { + const identity = await enclosingPackageFor(candidate, snapshot.readBytes); + if (!identity) { + throw new Error(`reachable code has no owning package below ${projectRoot}: ${candidate}`); + } + return { + boundary: identity.directory, + fileName: candidate, + identity, + kind: "package" as const + }; + })(); + const validated = await validateStaticSourcePath( + candidate, + source.boundary, + DYNAMICS_STATIC_CLOSURE_POLICY + ); + return { ...source, fileName: validated }; +}; + +/** Checks every Node search candidate before TypeScript can read package metadata. */ +export const validateStaticPackageSearchPaths = async ( + specifier: string, + importerFileName: string, + projectRoot: string, + policy: StaticClosurePolicy = DYNAMICS_STATIC_CLOSURE_POLICY +): Promise => { + const segments = specifier.startsWith("@") ? specifier.split("/").slice(0, 2) : [specifier]; + for (let directory = path.dirname(path.resolve(importerFileName));;) { + if (path.basename(directory) !== "node_modules") { + const modules = path.join(directory, "node_modules"); + const shared = staticSharedPathAnchor(projectRoot, modules); + const boundary = path.resolve(shared) === path.resolve(modules) ? path.dirname(shared) : shared; + try { + await validateStaticDirectoryPath(modules, boundary, policy); + await validateStaticDirectoryPath(path.join(modules, ...segments), boundary, policy); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error; + } + } + const parent = path.dirname(directory); + if (parent === directory) return; + directory = parent; + } +}; + +/** Resolves a project-local package entry only after each package path is checked. */ +export const checkedProjectPackageEntry = async ( + specifier: string, + projectRoot: string, + policy: StaticClosurePolicy = DYNAMICS_STATIC_CLOSURE_POLICY +): Promise => { + if (specifier.startsWith(".") || specifier.startsWith("node:")) return undefined; + const segments = specifier.startsWith("@") ? specifier.split("/").slice(0, 2) : [specifier]; + const modules = path.join(projectRoot, "node_modules"); + const directory = path.join(modules, ...segments); + try { + await validateStaticDirectoryPath(modules, projectRoot, policy); + await validateStaticDirectoryPath(directory, projectRoot, policy); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } + const manifest = path.join(directory, "package.json"); + const fileName = await validateStaticSourcePath(manifest, directory, policy); + const parsed = JSON.parse(new TextDecoder().decode(await readFile(fileName))) as { main?: unknown; module?: unknown }; + const entry = typeof parsed.module === "string" ? parsed.module : parsed.main; + return typeof entry === "string" && entry.startsWith("./") ? path.join(directory, entry) : undefined; +}; + +export const policyHas = (values: readonly string[], value: string): boolean => values.includes(value); + +export const unwrapStaticExpression = (expression: ts.Expression, policy: StaticClosurePolicy, compiler: typeof ts = ts): ts.Expression => { + let current = expression; + while (true) { + if (compiler.isParenthesizedExpression(current) && policyHas(policy.source.expressionUnwrap, "parenthesized")) current = current.expression; + else if (compiler.isAsExpression(current) && policyHas(policy.source.expressionUnwrap, "as-expression")) current = current.expression; + else if (compiler.isTypeAssertionExpression(current) && policyHas(policy.source.expressionUnwrap, "type-assertion")) current = current.expression; + else if (compiler.isNonNullExpression(current) && policyHas(policy.source.expressionUnwrap, "non-null")) current = current.expression; + else return current; + } +}; + +export const unwrapStaticCallExpression = (expression: ts.Expression, policy: StaticClosurePolicy): ts.Expression => { + let current = expression; + while (true) { + const unwrapped = unwrapStaticExpression(current, policy); + if (ts.isBinaryExpression(unwrapped) && unwrapped.operatorToken.kind === ts.SyntaxKind.CommaToken) current = unwrapped.right; + else return unwrapped; + } +}; + +const literalValue = (expression: ts.Expression, policy: StaticClosurePolicy): string | undefined => { + const unwrapped = unwrapStaticExpression(expression, policy); + if (ts.isStringLiteral(unwrapped) && policyHas(policy.source.literalSpecifierKinds, "string-literal")) return unwrapped.text; + if (ts.isNoSubstitutionTemplateLiteral(unwrapped) && policyHas(policy.source.literalSpecifierKinds, "no-substitution-template")) return unwrapped.text; + if (ts.isBinaryExpression(unwrapped) && unwrapped.operatorToken.kind === ts.SyntaxKind.PlusToken) { + const left = literalValue(unwrapped.left, policy); + const right = literalValue(unwrapped.right, policy); + if (left !== undefined && right !== undefined) return `${left}${right}`; + } + return undefined; +}; + +export const staticLiteralSpecifierValue = (expression: ts.Expression, policy: StaticClosurePolicy, compiler: typeof ts = ts): string | undefined => { + const unwrapped = unwrapStaticExpression(expression, policy, compiler); + if (compiler.isStringLiteral(unwrapped) && policyHas(policy.source.literalSpecifierKinds, "string-literal")) return unwrapped.text; + if (compiler.isNoSubstitutionTemplateLiteral(unwrapped) && policyHas(policy.source.literalSpecifierKinds, "no-substitution-template")) return unwrapped.text; + return undefined; +}; + +export const assertStaticSourceFileName = (fileName: string, policy: StaticClosurePolicy, compiler: typeof ts = ts): ts.ScriptKind => { + const isTypeScript = policy.source.typeScriptExtensions.some((extension) => fileName.endsWith(extension)); + const isJavaScript = policy.source.javaScriptExtensions.some((extension) => fileName.endsWith(extension)); + const scriptKind = isTypeScript && policyHas(policy.source.scriptKinds, "ts") ? compiler.ScriptKind.TS + : isJavaScript && policyHas(policy.source.scriptKinds, "js") ? compiler.ScriptKind.JS : undefined; + if (scriptKind === undefined) throw new Error(`static source has an unsupported extension: ${fileName}`); + if (policy.source.scriptTarget !== "ES2022") throw new Error("static source has an unsupported script target"); + return scriptKind; +}; + +export const staticSourceFile = (fileName: string, text: string, policy: StaticClosurePolicy, compiler: typeof ts = ts): ts.SourceFile => { + const scriptKind = assertStaticSourceFileName(fileName, policy, compiler); + const parsed = compiler.createSourceFile(fileName, text, compiler.ScriptTarget.ES2022, true, scriptKind); + const diagnostics = (parsed as unknown as Readonly<{ parseDiagnostics: readonly ts.Diagnostic[] }>).parseDiagnostics; + if (diagnostics.length > 0) throw new Error(`static source does not parse: ${fileName}`); + return parsed; +}; + +/** Lists every literal compiler-observed module and type-reference edge without evaluation. */ +export const staticSourceSpecifiers = ( + fileName: string, + text: string, + policy: StaticClosurePolicy, + compiler: typeof ts = ts, +): readonly StaticSourceSpecifier[] => { + const parsed = staticSourceFile(fileName, text, policy, compiler); + const found: StaticSourceSpecifier[] = []; + const add = (expression: ts.Expression | undefined, kind: StaticSourceSpecifier["kind"], mode: StaticSourceSpecifier["mode"]): void => { + const specifier = expression && staticLiteralSpecifierValue(expression, policy, compiler); + if (specifier !== undefined) found.push({ kind, mode, specifier }); + }; + const typeOnlyImport = (node: ts.ImportDeclaration): boolean => !!node.importClause && (node.importClause.isTypeOnly + || (node.importClause.name === undefined && !!node.importClause.namedBindings && compiler.isNamedImports(node.importClause.namedBindings) + && node.importClause.namedBindings.elements.length > 0 && node.importClause.namedBindings.elements.every((item) => item.isTypeOnly))); + const typeOnlyExport = (node: ts.ExportDeclaration): boolean => node.isTypeOnly || (!!node.exportClause && compiler.isNamedExports(node.exportClause) + && node.exportClause.elements.length > 0 && node.exportClause.elements.every((item) => item.isTypeOnly)); + const visit = (node: ts.Node): void => { + if (compiler.isImportDeclaration(node)) add(node.moduleSpecifier, "module", typeOnlyImport(node) ? "type-only" : "runtime"); + else if (compiler.isExportDeclaration(node) && node.moduleSpecifier) add(node.moduleSpecifier, "module", typeOnlyExport(node) ? "type-only" : "runtime"); + else if (compiler.isImportEqualsDeclaration(node) && compiler.isExternalModuleReference(node.moduleReference)) add(node.moduleReference.expression, "module", node.isTypeOnly ? "type-only" : "runtime"); + else if (compiler.isImportTypeNode(node) && compiler.isLiteralTypeNode(node.argument)) add(node.argument.literal, "module", "type-only"); + else if (compiler.isCallExpression(node) && node.expression.kind === compiler.SyntaxKind.ImportKeyword) add(node.arguments[0], "module", "runtime"); + else if (compiler.isCallExpression(node) && compiler.isIdentifier(node.expression) && node.expression.text === policy.source.requireIdentifier) add(node.arguments[0], "module", "runtime"); + compiler.forEachChild(node, visit); + }; + visit(parsed); + for (const reference of parsed.typeReferenceDirectives) found.push({ kind: "type-reference", mode: "type-only", specifier: reference.fileName }); + for (const reference of parsed.referencedFiles) found.push({ kind: "path-reference", mode: "type-only", specifier: reference.fileName }); + return found; +}; + +/** Resolves and reads a finite syntax graph only after each target path passes the supplied gate. */ +export const preflightStaticGraph = async ( + entries: readonly string[], + policy: StaticClosurePolicy, + adapter: StaticGraphAdapter +): Promise => { + const queued = [...entries]; + const checked = new Map(); + for (let index = 0; index < queued.length; index += 1) { + const source = await adapter.validate(queued[index] as string); + if (checked.has(source.fileName)) continue; + checked.set(source.fileName, source); + assertStaticSourceFileName(source.fileName, policy); + const text = await adapter.read(source.fileName); + await adapter.inspect(source, text); + for (const specifier of staticSourceSpecifiers(source.fileName, text, policy)) { + if (specifier.kind === "path-reference") assertStaticPathReference(specifier.specifier, policy); + const fileName = await adapter.resolve(specifier, source); + if (fileName === undefined) continue; + const target = await adapter.validate(fileName); + await adapter.edge(source, target, specifier); + if (!checked.has(target.fileName)) queued.push(target.fileName); + } + } + return [...checked.values()]; +}; + +export const isStaticResolverIdentifier = (expression: ts.Expression, policy: StaticClosurePolicy): boolean => { + const unwrapped = unwrapStaticExpression(expression, policy); + return ts.isIdentifier(unwrapped) && policyHas(policy.emitted.resolverIdentifiers, unwrapped.text); +}; + +export const staticMemberName = (node: ts.PropertyAccessExpression | ts.ElementAccessExpression, policy: StaticClosurePolicy): string | undefined => { + if (ts.isPropertyAccessExpression(node)) return node.name.text; + return node.argumentExpression === undefined ? undefined : literalValue(node.argumentExpression, policy); +}; + +export const isStaticResolverProperty = (node: ts.PropertyAccessExpression | ts.ElementAccessExpression, policy: StaticClosurePolicy): boolean => { + const member = staticMemberName(node, policy); + return member === policy.source.requireIdentifier || policyHas(policy.emitted.resolverIdentifiers, member ?? ""); +}; + +export const staticWrappedExpressionCarrier = (node: ts.Node): ts.Node => { + let carrier: ts.Node = node; + while (carrier.parent) { + if (ts.isParenthesizedExpression(carrier.parent) || ts.isAsExpression(carrier.parent) + || ts.isTypeAssertionExpression(carrier.parent) || ts.isNonNullExpression(carrier.parent)) carrier = carrier.parent; + else if (ts.isBinaryExpression(carrier.parent) && carrier.parent.operatorToken.kind === ts.SyntaxKind.CommaToken + && carrier.parent.right === carrier) carrier = carrier.parent; + else break; + } + return carrier; +}; + +export const collectStaticResolverAliases = (node: ts.Node, policy: StaticClosurePolicy, aliases: Set): void => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer !== undefined + && isStaticResolverIdentifier(node.initializer, policy)) aliases.add(node.name.text); + ts.forEachChild(node, (child) => collectStaticResolverAliases(child, policy, aliases)); +}; + +export const isStaticCallOnResolver = (expression: ts.Expression, policy: StaticClosurePolicy, aliases: ReadonlySet): boolean => { + const callee = unwrapStaticCallExpression(expression, policy); + if (ts.isIdentifier(callee) && aliases.has(callee.text)) return true; + if (isStaticResolverIdentifier(callee, policy)) return true; + if (!ts.isPropertyAccessExpression(callee) && !ts.isElementAccessExpression(callee)) return false; + const property = staticMemberName(callee, policy); + if (property !== undefined && policyHas(policy.emitted.resolverIdentifiers, property)) return true; + return (property === "call" || property === "apply") + && (isStaticResolverIdentifier(callee.expression, policy) || (ts.isIdentifier(callee.expression) && aliases.has(callee.expression.text))); +}; diff --git a/src/dynamics/buildTestSupport.test-helper.ts b/src/dynamics/buildTestSupport.test-helper.ts new file mode 100644 index 0000000..67ead84 --- /dev/null +++ b/src/dynamics/buildTestSupport.test-helper.ts @@ -0,0 +1,390 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { + compareUtf16, + createDynamicsClosureIdentity, + sha256, + type DynamicsBuildInputDescriptor +} from "./buildIdentity.js"; +import { prepareDynamicsBuild } from "./build.js"; + +type UnknownRecord = Readonly>; +type DynamicsClosureShape = { + readonly buildContract: UnknownRecord; + readonly entry: string; + readonly esbuildVersion: string; + readonly inputs: readonly DynamicsBuildInputDescriptor[]; + readonly preparationPolicy: UnknownRecord; + readonly typecheckMode: "none" | "typescript"; + readonly typescriptVersion: string; + readonly usedNodeBuiltins: readonly string[]; +}; + +export interface BuildTestProject { + readonly directory: string; + readonly simfilePath: string; +} + +export type LocaleProcessLocale = "en-US" | "sv-SE"; + +export interface LocaleChildResult { + readonly childPid: number; + readonly requestedLocale: LocaleProcessLocale; + readonly resolvedLocale: string; + readonly controlOrder: readonly string[]; + readonly prepared: PreparedBuild; + readonly localeEnvironment: { + readonly lang: string | undefined; + readonly lcAll: string | undefined; + }; +} + +export type PreparedBuild = Awaited>; + +export interface InputMutationComparison { + readonly baseline: PreparedBuild; + readonly mutated: PreparedBuild; + readonly expectedChangedInputKey: string; +} + +const buildModuleUrl = new URL("./build.js", import.meta.url).href; + +const localeEnvironment = Object.freeze({ + "en-US": "en_US.UTF-8", + "sv-SE": "sv_SE.UTF-8" +} as const satisfies Record); + +const readStreamText = async (stream: NodeJS.ReadableStream): Promise => { + const chunks: Buffer[] = []; + for await (const chunk of stream) chunks.push(Buffer.from(chunk)); + return Buffer.concat(chunks).toString("utf8"); +}; + +const isDeepEqual = (left: unknown, right: unknown): boolean => { + try { + assert.deepEqual(left, right); + return true; + } catch { + return false; + } +}; + +const makeInputDescriptorKey = (descriptor: DynamicsBuildInputDescriptor): string => { + switch (descriptor.kind) { + case "project": + return `project:${descriptor.path}`; + case "package": + return `package:${descriptor.package_name}:${descriptor.package_path}`; + default: + return "type-only:simfile:dynamics"; + } +}; + +const mapInputDescriptorsByKey = (prepared: PreparedBuild): Map => { + const entries = new Map(); + for (const input of prepared.inputs) { + entries.set(makeInputDescriptorKey(input), input); + } + return entries; +}; + +export const assertPreparedSingleInputMutation = (fixture: InputMutationComparison): void => { + const baselineInputs = mapInputDescriptorsByKey(fixture.baseline); + const mutatedInputs = mapInputDescriptorsByKey(fixture.mutated); + const baselineKeys = [...baselineInputs.keys()].sort(compareUtf16); + const mutatedKeys = [...mutatedInputs.keys()].sort(compareUtf16); + assert.deepEqual(baselineKeys, mutatedKeys); + const changedInputKeys = baselineKeys.filter((key) => + !isDeepEqual(baselineInputs.get(key), mutatedInputs.get(key)) + ); + assert.deepEqual(changedInputKeys.length, 1); + assert.deepEqual(changedInputKeys[0], fixture.expectedChangedInputKey); + assert.equal( + isDeepEqual(fixture.baseline.closureDescriptor, fixture.mutated.closureDescriptor), + false + ); + assert.equal(fixture.baseline.closureSha256 === fixture.mutated.closureSha256, false); + assert.equal(closureHeader(fixture.baseline) === closureHeader(fixture.mutated), false); + assert.equal(fixture.baseline.artifactSha256 === fixture.mutated.artifactSha256, false); + assert.equal(isDeepEqual(fixture.baseline.artifactBytes, fixture.mutated.artifactBytes), false); + assert.deepEqual(fixture.baseline.inputs.length, fixture.mutated.inputs.length); +}; +export const assertUnreachableFileMutationNoEffect = async ( + project: BuildTestProject +): Promise => { + await writeBuildFile(project, "systems/provider.mjs", "export const value = 1;\n"); + const baseline = await prepareBuild(project); + await writeBuildFile(project, "systems/unrelated.ts", "export const value = 1;\n"); + const afterAdd = await prepareBuild(project); + assert.deepEqual(afterAdd, baseline); + await writeBuildFile(project, "systems/unrelated.ts", "export const value = 2;\n"); + const afterMutate = await prepareBuild(project); + assert.deepEqual(afterMutate, baseline); +}; +export const makePackage = async ( + projectDirectory: string, + name: string, + version: string, + source: string, + entry = "./index.mjs" +): Promise => { + const pkgDirectory = path.join(projectDirectory, "node_modules", name); + await writeBuildFile({ directory: pkgDirectory }, "package.json", JSON.stringify({ name, version, type: "module", main: entry })); + await writeBuildFile({ directory: pkgDirectory }, entry.slice(2), source); + return pkgDirectory; +}; +export const runPreparedSingleInputMutationFixture = async ( + setup: (project: BuildTestProject) => Promise, + mutate: (project: BuildTestProject) => Promise, + expectedChangedInputKey: string, + module = "./systems/provider.mjs" +): Promise => { + const project = await createBuildTestProject(); + try { + await setup(project); + const baseline = await prepareBuild(project, module); + const mutated = await mutate(project); + assertPreparedSingleInputMutation({ baseline, mutated, expectedChangedInputKey }); + } finally { + await removeBuildTestPaths(project.directory); + } +}; +const asStrings = (value: unknown): readonly string[] => Array.isArray(value) + ? value.filter((entry): entry is string => typeof entry === "string") + : []; +const asRecord = (value: unknown): UnknownRecord => (value && typeof value === "object" && !Array.isArray(value)) + ? value as UnknownRecord + : {}; +export const buildShapeFromPrepared = (prepared: PreparedBuild): DynamicsClosureShape => ({ + buildContract: asRecord(prepared.closureDescriptor.build_contract), + entry: prepared.closureDescriptor.entry as string, + esbuildVersion: prepared.closureDescriptor.esbuild_version as string, + inputs: prepared.inputs, + preparationPolicy: asRecord(prepared.closureDescriptor.preparation_policy), + typecheckMode: prepared.typecheckMode, + typescriptVersion: prepared.closureDescriptor.typescript_version as string, + usedNodeBuiltins: asStrings(prepared.closureDescriptor.used_node_builtins) +}) as DynamicsClosureShape; +export const assertAxisMutationHeaderEffect = ( + baseline: PreparedBuild, + buildMutated: (shape: DynamicsClosureShape) => ReturnType +): void => { + const mutated = buildMutated(buildShapeFromPrepared(baseline)); + assert.notDeepEqual(mutated.descriptor, baseline.closureDescriptor); + assert.equal(mutated.sha256 === baseline.closureSha256, false); + assert.equal(mutated.header.includes(mutated.sha256), true); + assert.equal(mutated.header === closureHeader(baseline), false); + const headerBytes = new TextEncoder().encode(closureHeader(baseline)); + const emittedBody = Buffer.from(baseline.artifactBytes.slice(headerBytes.length)); + const mutatedHeaderBytes = new TextEncoder().encode(mutated.header); + const rebuilt = new Uint8Array(mutatedHeaderBytes.length + emittedBody.length); + rebuilt.set(mutatedHeaderBytes); + rebuilt.set(emittedBody, mutatedHeaderBytes.length); + const rebuiltBody = Buffer.from(rebuilt.slice(mutatedHeaderBytes.length)); + assert.equal(rebuiltBody.compare(emittedBody), 0); + assert.equal(sha256(rebuilt) === baseline.artifactSha256, false); +}; +export const runAxisMutationFixture = async ( + buildMutated: (shape: DynamicsClosureShape) => ReturnType +): Promise => { + const project = await createBuildTestProject(); + try { + await writeBuildFile(project, "systems/provider.mjs", "export const value = 1;\n"); + const baseline = await prepareBuild(project); + assertAxisMutationHeaderEffect(baseline, buildMutated); + } finally { + await removeBuildTestPaths(project.directory); + } +}; +export const createBuildTestProject = async (parent = os.tmpdir()): Promise => { + await mkdir(parent, { recursive: true }); + const directory = await mkdtemp(path.join(parent, "simfile-build-")); + const simfilePath = path.join(directory, "Simfile"); + await writeFile(simfilePath, "clock: {}\n", "utf8"); + return { directory, simfilePath }; +}; +export const writeBuildFile = async (project: Pick, relative: string, source: string): Promise => { + const fileName = path.join(project.directory, ...relative.split("/")); + await mkdir(path.dirname(fileName), { recursive: true }); + await writeFile(fileName, source, "utf8"); + return fileName; +}; +export const prepareBuild = (project: BuildTestProject, module = "./systems/provider.mjs") => + prepareDynamicsBuild(project.simfilePath, module); +export const removeBuildTestPaths = async (...paths: string[]): Promise => { + await Promise.all(paths.map((entry) => rm(entry, { force: true, recursive: true }))); +}; +export const artifactText = (prepared: PreparedBuild): string => + Buffer.from(prepared.artifactBytes).toString("utf8"); +export const closureHeader = (prepared: PreparedBuild): string => + `/* simfile-dynamics-closure-sha256:${prepared.closureSha256} */\n`; +export const preparedArtifactBody = (prepared: PreparedBuild): readonly number[] => { + const headerLength = new TextEncoder().encode(closureHeader(prepared)).byteLength; + return prepared.artifactBytes.slice(headerLength); +}; +export const preparedArtifactText = (prepared: PreparedBuild): string => + Buffer.from(preparedArtifactBody(prepared)).toString("utf8"); +export const collectStringValues = (value: unknown, output: string[] = []): string[] => { + if (typeof value === "string") { + output.push(value); + return output; + } + if (Array.isArray(value)) { + for (const child of value) collectStringValues(child, output); + return output; + } + if (value && typeof value === "object") { + for (const child of Object.values(value as Record)) collectStringValues(child, output); + } + return output; +}; +const absolutePathPatterns = [ + /(?:^|[\s"'`])([A-Za-z]:[\\/][^\s"'`]+)/gu, + /(?:^|[\s"'`])(\/[A-Za-z0-9._\-~%+]+(?:\/[A-Za-z0-9._\-~%+]+)*)/gu, +] as const; +const isAllowedTextReference = (value: string): boolean => + value.startsWith("node:") || value.startsWith("./") || value.startsWith("../") || value === "dynamics.mjs"; +export const assertNoFilesystemPathLeaks = (prepared: PreparedBuild, fixtureRoot: string): void => { + const parentRoot = path.resolve(path.dirname(fixtureRoot)); + const forbidden = [fixtureRoot, parentRoot].flatMap((candidate) => { + const normalized = path.resolve(candidate); + return [ + candidate, + normalized, + normalized.replace(/\\/gu, "/"), + path.posix.normalize(normalized), + path.win32.normalize(normalized) + ]; + }); + const values = [ + ...collectStringValues(prepared), + artifactText(prepared) + ]; + const trimPunctuation = (value: string): string => + value.replace(/[),.;:]$/u, "").replace(/[*]+$/u, ""); + const hasAbsolutePath = (value: string): boolean => { + for (const pattern of absolutePathPatterns) { + for (const match of value.matchAll(pattern)) { + const candidate = match[1] ?? match[0].trimStart(); + if (!candidate) continue; + if (candidate.startsWith("/*")) continue; + const portable = trimPunctuation(candidate); + if (isAllowedTextReference(candidate)) continue; + if (path.posix.isAbsolute(portable)) return true; + if (/^[A-Za-z]:[\\/]/u.test(portable) || /^\\\\/u.test(portable)) return true; + } + } + return false; + }; + for (const value of values) { + assert.equal( + forbidden.some((candidate) => candidate.length > 0 && value.includes(candidate)), + false, + `forbidden root leak: ${value}` + ); + assert.equal( + hasAbsolutePath(value), + false, + `unexpected absolute filesystem path: ${value}` + ); + } +}; +export const prepareBuildInLocaleChild = async ( + projectDirectory: string, + locale: LocaleProcessLocale, + module = "./systems/provider.mjs" +): Promise => { + const expectedEnvironment = localeEnvironment[locale]; + const script = [ + "import path from \"node:path\";", + `import { prepareDynamicsBuild } from \"${buildModuleUrl}\";`, + `const projectDirectory = ${JSON.stringify(projectDirectory)};`, + `const moduleReference = ${JSON.stringify(module)};`, + "const requestedLocale = process.env.SIMFILE_TEST_LOCALE ?? \"en-US\";", + "const expectedEnvironment = process.env.SIMFILE_TEST_LOCALE_ENV ?? undefined;", + "if (!expectedEnvironment) throw new Error(\"missing expected child locale environment\");", + "const lang = process.env.LANG;", + "const lcAll = process.env.LC_ALL;", + "if (lang !== expectedEnvironment || lcAll !== expectedEnvironment) {", + " throw new Error(`child locale env mismatch: LANG=${lang} LC_ALL=${lcAll} expected=${expectedEnvironment}`);", + "}", + "const normalizedRequested = requestedLocale.replace('_', '-').toLowerCase();", + "const collator = new Intl.Collator(undefined, { usage: \"sort\", sensitivity: \"variant\" });", + "const resolvedLocale = collator.resolvedOptions().locale;", + "if (!resolvedLocale.toLowerCase().startsWith(normalizedRequested)) {", + " throw new Error(`child locale mismatch: ${requestedLocale} -> ${resolvedLocale}`);", + "}", + "const controlOrder = [\"ä\", \"z\"].sort((left, right) => collator.compare(left, right));", + "const prepared = await prepareDynamicsBuild(path.join(projectDirectory, \"Simfile\"), moduleReference);", + "process.stdout.write(JSON.stringify({", + " childPid: process.pid,", + " requestedLocale,", + " resolvedLocale,", + " controlOrder,", + " localeEnvironment: {", + " lang,", + " lcAll", + " },", + " prepared", + "}));" + ].join("\n"); + const child = spawn(process.execPath, ["--import", "tsx", "--input-type=module", "--eval", script], { + env: { + ...process.env, + LANG: expectedEnvironment, + LC_ALL: expectedEnvironment, + SIMFILE_TEST_LOCALE: locale, + SIMFILE_TEST_LOCALE_ENV: expectedEnvironment + }, + stdio: ["ignore", "pipe", "pipe"] + }); + const closePromise = new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => { + child.on("close", (code, signal) => resolve({ code, signal })); + }); + const [stdout, stderr, close] = await Promise.all([ + readStreamText(child.stdout), + readStreamText(child.stderr), + closePromise + ]); + if (close.signal !== null) throw new Error(`child terminated with signal ${String(close.signal)}: ${stderr}`); + if (close.code !== 0) throw new Error(`child exited with code ${close.code}: ${stderr}`); + try { + const payload = JSON.parse(stdout) as { + childPid?: number; + requestedLocale?: LocaleProcessLocale; + resolvedLocale?: string; + controlOrder?: readonly string[]; + localeEnvironment?: { lang?: string; lcAll?: string }; + prepared?: PreparedBuild; + }; + if ( + typeof payload.childPid !== "number" || + payload.requestedLocale !== locale || + typeof payload.resolvedLocale !== "string" || + !Array.isArray(payload.controlOrder) || + payload.controlOrder.length !== 2 || + typeof payload.prepared?.artifactBytes?.length !== "number" || + typeof payload.localeEnvironment?.lang !== "string" || + typeof payload.localeEnvironment?.lcAll !== "string" + ) { + throw new Error("malformed child payload"); + } + return { + childPid: payload.childPid, + requestedLocale: payload.requestedLocale, + resolvedLocale: payload.resolvedLocale, + controlOrder: payload.controlOrder, + prepared: payload.prepared, + localeEnvironment: { + lang: payload.localeEnvironment.lang, + lcAll: payload.localeEnvironment.lcAll + } + }; + } catch (error) { + throw new Error(`malformed child output: ${error instanceof Error ? error.message : String(error)}: ${stdout}`); + } +}; diff --git a/src/dynamics/buildTypecheck.ts b/src/dynamics/buildTypecheck.ts new file mode 100644 index 0000000..b2f5fe1 --- /dev/null +++ b/src/dynamics/buildTypecheck.ts @@ -0,0 +1,278 @@ +import { existsSync } from "node:fs"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import ts from "typescript"; + +import { DYNAMICS_BUILD_CONTRACT, DYNAMICS_BUILD_PREPARATION_POLICY } from "./buildInput.js"; +import { compareUtf16 } from "./buildIdentity.js"; +import type { StaticCompilerReadGuard } from "./buildStaticCompilerHostPolicy.js"; + +export interface DynamicsTypecheckResult { + readonly inputPaths: readonly string[]; + readonly typeSurface?: Readonly<{ files: readonly string[]; root: string }>; +} + +const { + source: sourcePolicy, + suppression: suppressionPolicy, + simfileDynamics, + typescript: typescriptPolicy, + package: packagePolicy +} = DYNAMICS_BUILD_PREPARATION_POLICY; + +const declarationExtensions = sourcePolicy.declarationExtensions as readonly string[]; +const checkedExtensions = sourcePolicy.checkedExtensions as readonly string[]; +const javaScriptSyntaxExtensions = sourcePolicy.javaScriptSyntaxExtensions as readonly string[]; +const rejectRuntimeExtensions = sourcePolicy.rejectRuntimeTypeExtensions as readonly string[]; +const transformExtensions = sourcePolicy.transformExtensions as readonly string[]; +const fullLineSuppressions = suppressionPolicy.fullLineDirectives as readonly string[]; +const acceptedErasedForms = new Set(simfileDynamics.acceptedErasedForms); +const runtimeCallForms = new Set(simfileDynamics.runtimeCallForms); +const runtimeExpressionUnwrap = new Set(simfileDynamics.runtimeExpressionUnwrap); +const compileModuleKind = (() => { + switch (typescriptPolicy.compilerOptions.module) { + case "NodeNext": return ts.ModuleKind.NodeNext; + default: throw new Error(`unsupported TypeScript module setting: ${typescriptPolicy.compilerOptions.module}`); + } +})(); +const compileModuleResolution = (() => { + switch (typescriptPolicy.compilerOptions.moduleResolution) { + case "NodeNext": return ts.ModuleResolutionKind.NodeNext; + default: throw new Error(`unsupported TypeScript module resolution setting: ${typescriptPolicy.compilerOptions.moduleResolution}`); + } +})(); +const compileTarget = (() => { + switch (typescriptPolicy.compilerOptions.target) { + case "ES2022": return ts.ScriptTarget.ES2022; + default: throw new Error(`unsupported TypeScript target setting: ${typescriptPolicy.compilerOptions.target}`); + } +})(); + +const hasExtension = (fileName: string, extension: string): boolean => fileName.endsWith(extension); +const hasAnyExtension = (fileName: string, extensions: readonly string[]): boolean => + extensions.some((extension) => hasExtension(fileName, extension)); + +export const isTypeScriptFamily = (fileName: string): boolean => hasAnyExtension(fileName, checkedExtensions); +export const isDeclarationFile = (fileName: string): boolean => hasAnyExtension(fileName, declarationExtensions); +export const isTransformableTypeScriptSource = (fileName: string): boolean => + !isDeclarationFile(fileName) && hasAnyExtension(fileName, transformExtensions); +export const isRejectedRuntimeTypeScriptSource = (fileName: string): boolean => + hasAnyExtension(fileName, rejectRuntimeExtensions) && !isDeclarationFile(fileName); + +const isJavaScriptSource = (fileName: string): boolean => + hasAnyExtension(fileName, javaScriptSyntaxExtensions); + +const hostTypeRoot = (() => { + switch (typescriptPolicy.typeRootResolution) { + case "package-parent": { + const manifest = createRequire(import.meta.url).resolve( + `${typescriptPolicy.typeRootPackage}/${packagePolicy.manifestFileName}` + ); + return path.dirname(path.dirname(manifest)); + } + default: + throw new Error(`unsupported TypeScript type-root resolution: ${typescriptPolicy.typeRootResolution}`); + } +})(); + +/** Host-owned type roots are explicit preparation inputs, never project discovery. */ +export const dynamicsConfiguredTypeRoots = (): readonly string[] => [hostTypeRoot]; + +const compilerOptions: ts.CompilerOptions = { + allowJs: typescriptPolicy.allowJs, + checkJs: typescriptPolicy.checkJs, + allowImportingTsExtensions: typescriptPolicy.compilerOptions.allowImportingTsExtensions, + module: compileModuleKind, + moduleResolution: compileModuleResolution, + noEmit: DYNAMICS_BUILD_CONTRACT.typescript.noEmit, + strict: DYNAMICS_BUILD_CONTRACT.typescript.strict, + target: compileTarget, + typeRoots: [hostTypeRoot], + types: [...typescriptPolicy.types] +}; + +export const dynamicsDefaultLibraryRoot = (): string => path.dirname(path.dirname(ts.getDefaultLibFilePath(compilerOptions))); + +/** Resolves configured `types` through the same guarded host used by the compiler. */ +export const dynamicsConfiguredTypeEntries = (host: ts.ModuleResolutionHost): readonly string[] => { + const entries = typescriptPolicy.types.map((name) => { + const containing = path.join(hostTypeRoot, "__simfile_configured_types__.ts"); + const resolved = ts.resolveTypeReferenceDirective(name, containing, compilerOptions, host).resolvedTypeReferenceDirective; + if (!resolved?.resolvedFileName) throw new Error(`dynamics TypeScript check cannot resolve configured type: ${name}`); + return path.resolve(resolved.resolvedFileName); + }); + return [...new Set(entries)].sort(compareUtf16); +}; + +const isTypeScriptSyntaxFile = (fileName: string): boolean => isTypeScriptFamily(fileName) && !isJavaScriptSource(fileName); + +const formatDiagnostics = (diagnostics: readonly ts.Diagnostic[]): string => ts.formatDiagnosticsWithColorAndContext(diagnostics, { + getCanonicalFileName: (name) => name, getCurrentDirectory: () => "", getNewLine: () => "\n" +}).replaceAll(/\x1b\[[0-9;]*m/g, ""); + +const isTypeOnlyImportClause = (clause: ts.ImportClause): boolean => { + if (clause.isTypeOnly) return acceptedErasedForms.has("import-clause"); + if (clause.name !== undefined) return false; + const bindings = clause.namedBindings; + if (!bindings || !ts.isNamedImports(bindings) || bindings.elements.length === 0) return false; + return acceptedErasedForms.has("import-specifiers") + && bindings.elements.every((specifier) => specifier.isTypeOnly); +}; + +const isTypeOnlyExport = (clause: ts.ExportDeclaration): boolean => { + if (clause.isTypeOnly) return acceptedErasedForms.has("export-declaration"); + if (!clause.exportClause) return false; + if (ts.isNamespaceExport(clause.exportClause)) return acceptedErasedForms.has("namespace-export"); + if (!ts.isNamedExports(clause.exportClause)) return false; + return acceptedErasedForms.has("export-specifiers") + && clause.exportClause.elements.length > 0 + && clause.exportClause.elements.every((item: ts.ExportSpecifier) => item.isTypeOnly); +}; + +const isDynamics = (value: unknown): boolean => + !!value && (ts.isStringLiteral(value as ts.Node) || ts.isNoSubstitutionTemplateLiteral(value as ts.Node)) + && (value as { text: string }).text === simfileDynamics.moduleSpecifier; + +const isTypeOnlyImportType = (node: ts.Node): boolean => + acceptedErasedForms.has("import-type") && ts.isImportTypeNode(node) && isDynamics(node.argument); + +const unwrap = (node: ts.Expression): ts.Expression => { + if (runtimeExpressionUnwrap.has("parenthesized") && ts.isParenthesizedExpression(node)) return unwrap(node.expression); + if (runtimeExpressionUnwrap.has("as-expression") && ts.isAsExpression(node)) return unwrap(node.expression); + if (runtimeExpressionUnwrap.has("type-assertion") && ts.isTypeAssertionExpression(node)) return unwrap(node.expression); + if (runtimeExpressionUnwrap.has("non-null") && ts.isNonNullExpression(node)) return unwrap(node.expression); + if (runtimeExpressionUnwrap.has("comma-right") + && ts.isBinaryExpression(node) + && node.operatorToken.kind === ts.SyntaxKind.CommaToken) return unwrap(node.right); + return node; +}; + +const requireCallee = (node: ts.Expression): "direct" | "call" | undefined => { + const expression = unwrap(node); + if (runtimeCallForms.has("require-direct") + && ts.isIdentifier(expression) + && expression.text === "require") return "direct"; + return runtimeCallForms.has("require-call") + && ts.isPropertyAccessExpression(expression) + && expression.name.text === "call" + && requireCallee(expression.expression) === "direct" + ? "call" + : undefined; +}; + +const rejectRuntimeDynamicsSurface = (source: ts.SourceFile): void => { + const reject = (): never => { throw new Error(simfileDynamics.runtimeResolutionFailure); }; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) && isDynamics(node.moduleSpecifier)) { + const clause = node.importClause; + if (!clause || !isTypeOnlyImportClause(clause)) reject(); + } else if (ts.isExportDeclaration(node) && node.moduleSpecifier && isDynamics(node.moduleSpecifier)) { + if (!isTypeOnlyExport(node)) reject(); + } else if (isTypeOnlyImportType(node)) { + return; + } else if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference) && isDynamics(node.moduleReference.expression)) { + if (!acceptedErasedForms.has("import-equals") || !node.isTypeOnly) reject(); + } else if (ts.isCallExpression(node)) { + const callee = requireCallee(node.expression); + const dynamicImport = runtimeCallForms.has("dynamic-import") + && node.expression.kind === ts.SyntaxKind.ImportKeyword; + const argument = dynamicImport || callee === "direct" + ? node.arguments[0] + : callee === "call" + ? node.arguments[1] + : undefined; + if (isDynamics(argument as ts.Expression | undefined)) reject(); + } + ts.forEachChild(node, visit); + }; + visit(source); +}; + +/** Applies the simfile/dynamics syntax boundary to every parsed source kind. */ +export const assertNoRuntimeDynamicsSurface = (fileName: string, text: string): void => { + const kind = isTypeScriptSyntaxFile(fileName) ? ts.ScriptKind.TS : ts.ScriptKind.JS; + rejectRuntimeDynamicsSurface(ts.createSourceFile(fileName, text, compileTarget, true, kind)); +}; + +const escapeRegExp = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/gu, "\\$&"); +const suppressPattern = new RegExp( + `^\\s*//\\s*${escapeRegExp(suppressionPolicy.directivePrefix)}(?:${fullLineSuppressions.map(escapeRegExp).join("|")})\\b`, + "u" +); +const rejectDiagnosticSuppressions = (source: ts.SourceFile): void => { + if (!typescriptPolicy.rejectDiagnosticSuppressions) return; + if (suppressionPolicy.placement !== "full-line" + || !suppressionPolicy.commentKinds.includes("single-line")) { + throw new Error("unsupported dynamics diagnostic-suppression policy"); + } + const scanner = ts.createScanner(compileTarget, false, ts.LanguageVariant.Standard, source.text); + for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) { + if (token !== ts.SyntaxKind.SingleLineCommentTrivia) continue; + const start = scanner.getTokenPos(); + const lineStart = source.text.lastIndexOf("\n", start - 1) + 1; + if (source.text.slice(lineStart, start).trim() !== "" || !suppressPattern.test(scanner.getTokenText())) continue; + throw new Error(`dynamics TypeScript check rejects diagnostic suppression in ${source.fileName}`); + } +}; + +/** Checks the reachable TS family with host-owned NodeNext manifests and no tsconfig lookup. */ +export const typecheckDynamicsModule = ( + entryPath: string, + projectRoot: string, + reachable: readonly string[], + guard?: StaticCompilerReadGuard, + declarationBackedJavaScriptRoots: ReadonlySet = new Set() +): DynamicsTypecheckResult => { + const sourcePaths = reachable.filter((fileName) => + isTypeScriptFamily(fileName) + && !(isJavaScriptSource(fileName) + && declarationBackedJavaScriptRoots.has(path.resolve(fileName))) + ).map((fileName) => path.resolve(fileName)).sort(compareUtf16); + if (sourcePaths.length === 0) return { inputPaths: [] }; + const surfacePath = dynamicsTypeSurfacePath(); + const surfaceRoot = path.dirname(surfacePath); + const host = ts.createCompilerHost(compilerOptions, true); + const readFile = host.readFile.bind(host); + const fileExists = host.fileExists.bind(host); + const manifestFileName = packagePolicy.manifestFileName; + const isProjectManifest = (fileName: string): boolean => path.basename(fileName) === manifestFileName + && path.relative(projectRoot, fileName) !== "" + && path.relative(projectRoot, fileName).split(path.sep) + .every((part) => part !== ".." && part !== packagePolicy.nodeModulesDirectory); + host.fileExists = (fileName) => isProjectManifest(path.resolve(fileName)) || (guard + ? guard.checkedResolutionHost.fileExists?.(fileName) ?? false : fileExists(fileName)); + host.readFile = (fileName) => isProjectManifest(path.resolve(fileName)) + ? JSON.stringify({ type: typescriptPolicy.projectPackageType }) : (guard + ? guard.checkedResolutionHost.readFile?.(fileName) : readFile(fileName)); + if (guard) { + const getSourceFile = host.getSourceFile.bind(host); + host.getSourceFile = (fileName, languageVersion, onError, shouldCreateNewSourceFile) => { + guard.assertCheckedRead(fileName); + return getSourceFile(fileName, languageVersion, onError, shouldCreateNewSourceFile); + }; + } + const resolveModuleNames = (names: string[], containing: string): (ts.ResolvedModule | undefined)[] => names.map((name) => name === simfileDynamics.moduleSpecifier ? { + extension: surfacePath.endsWith(".d.ts") ? ts.Extension.Dts : ts.Extension.Ts, isExternalLibraryImport: true, resolvedFileName: surfacePath + } : ts.resolveModuleName(name, containing, compilerOptions, guard?.checkedResolutionHost ?? host).resolvedModule); + guard?.freezePreflight(); + const program = ts.createProgram({ host: { ...host, getCurrentDirectory: () => projectRoot, resolveModuleNames }, options: compilerOptions, rootNames: sourcePaths.length ? sourcePaths : [entryPath] }); + for (const source of program.getSourceFiles()) { + if (isTypeScriptFamily(source.fileName)) { + assertNoRuntimeDynamicsSurface(source.fileName, source.text); + rejectDiagnosticSuppressions(source); + } + } + const diagnostics = ts.getPreEmitDiagnostics(program); + if (diagnostics.length) throw new Error(`dynamics TypeScript check failed:\n${formatDiagnostics(diagnostics)}`); + const defaultLib = path.dirname(ts.getDefaultLibFilePath(compilerOptions)); + const inputPaths = program.getSourceFiles().map((source) => path.resolve(source.fileName)) + .filter((fileName) => !fileName.startsWith(`${defaultLib}${path.sep}`)).sort(compareUtf16); + const surfaceFiles = inputPaths.filter((fileName) => fileName === surfacePath || fileName.startsWith(`${surfaceRoot}${path.sep}`)); + return { inputPaths, ...(surfaceFiles.length ? { typeSurface: { files: surfaceFiles, root: surfaceRoot } } : {}) }; +}; + +export const dynamicsTypeSurfacePath = (): string => { + const source = fileURLToPath(new URL("./index.ts", import.meta.url)); + return existsSync(source) ? source : fileURLToPath(new URL("./index.d.ts", import.meta.url)); +}; diff --git a/src/dynamics/canonicalJson.test.ts b/src/dynamics/canonicalJson.test.ts new file mode 100644 index 0000000..100f11e --- /dev/null +++ b/src/dynamics/canonicalJson.test.ts @@ -0,0 +1,45 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { canonicalDynamicsJson, cloneDynamicsJson } from "./canonicalJson.js"; +import { DYNAMICS_LIMITS } from "./limits.js"; + +describe("dynamics canonical JSON", () => { + it("is insertion-order independent and normalizes negative zero", () => { + assert.equal(canonicalDynamicsJson({ b: 2, a: -0 }), canonicalDynamicsJson({ a: 0, b: 2 })); + assert.notEqual(canonicalDynamicsJson({ a: "bc" }), canonicalDynamicsJson({ ab: "c" })); + assert.equal(Object.is((cloneDynamicsJson(-0) as number), -0), false); + }); + + it("rejects values JSON would serialize ambiguously", () => { + const sparse: unknown[] = []; + sparse[1] = "present"; + assert.throws(() => canonicalDynamicsJson(sparse), /sparse arrays/u); + assert.throws( + () => canonicalDynamicsJson(JSON.parse('{"__proto__":1}')), + /safe dynamics JSON key/u + ); + const withHidden = {}; + Object.defineProperty(withHidden, "hidden", { enumerable: false, value: 1 }); + assert.throws(() => canonicalDynamicsJson(withHidden), /enumerable data value/u); + }); + + it("enforces depth, node, per-string, and cumulative code-unit fuses", () => { + let deep: unknown = null; + for (let index = 0; index <= DYNAMICS_LIMITS.json_depth; index += 1) deep = [deep]; + assert.throws(() => canonicalDynamicsJson(deep), /depth limit/u); + assert.throws( + () => canonicalDynamicsJson(Array.from({ length: DYNAMICS_LIMITS.json_nodes + 1 }, () => null)), + /node limit/u + ); + assert.throws( + () => canonicalDynamicsJson("x".repeat(DYNAMICS_LIMITS.json_string_length + 1)), + /string limit/u + ); + const multiplicative = Object.fromEntries(Array.from( + { length: Math.floor(DYNAMICS_LIMITS.json_code_units / 1_000) + 1 }, + (_, index) => [`key_${index}`, "x".repeat(1_000)] + )); + assert.throws(() => canonicalDynamicsJson(multiplicative), /cumulative.*code-unit limit/u); + }); +}); diff --git a/src/dynamics/canonicalJson.ts b/src/dynamics/canonicalJson.ts new file mode 100644 index 0000000..fa4cb9a --- /dev/null +++ b/src/dynamics/canonicalJson.ts @@ -0,0 +1,112 @@ +import { DYNAMICS_LIMITS } from "./limits.js"; +import type { DynamicsJsonObject, DynamicsJsonValue } from "./types.js"; + +const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); + +interface JsonBudget { + codeUnits: number; + nodes: number; +} + +const consumeString = (value: string, path: string, budget: JsonBudget): string => { + if (value.length > DYNAMICS_LIMITS.json_string_length) { + throw new Error(`${path} exceeds the dynamics JSON string limit`); + } + budget.codeUnits += value.length; + if (budget.codeUnits > DYNAMICS_LIMITS.json_code_units) { + throw new Error(`${path} exceeds the cumulative dynamics JSON code-unit limit`); + } + return value; +}; + +const consumeNode = (budget: JsonBudget, depth: number, path: string): void => { + if (depth > DYNAMICS_LIMITS.json_depth) { + throw new Error(`${path} exceeds the dynamics JSON depth limit`); + } + budget.nodes += 1; + if (budget.nodes > DYNAMICS_LIMITS.json_nodes) { + throw new Error(`${path} exceeds the dynamics JSON node limit`); + } +}; + +const cloneArray = ( + value: unknown[], + path: string, + depth: number, + budget: JsonBudget +): DynamicsJsonValue[] => { + const ownKeys = Reflect.ownKeys(value); + if (ownKeys.some((key) => + typeof key !== "string" + || (key !== "length" && (!/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length)))) { + throw new Error(`${path} must not contain non-index array properties`); + } + const cloned: DynamicsJsonValue[] = []; + for (let index = 0; index < value.length; index += 1) { + if (!Object.hasOwn(value, index)) throw new Error(`${path} must not contain sparse arrays`); + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !("value" in descriptor)) { + throw new Error(`${path}[${index}] must be an enumerable data value`); + } + cloned.push(cloneValue(descriptor.value, `${path}[${index}]`, depth + 1, budget)); + } + return cloned; +}; + +const cloneObject = ( + value: object, + path: string, + depth: number, + budget: JsonBudget +): DynamicsJsonObject => { + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + throw new Error(`${path} must contain only plain JSON objects`); + } + const entries: Array<[string, DynamicsJsonValue]> = []; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string") throw new Error(`${path} must not contain symbol keys`); + consumeString(key, `${path} key`, budget); + if (DANGEROUS_KEYS.has(key)) throw new Error(`${path}.${key} is not a safe dynamics JSON key`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) { + throw new Error(`${path}.${key} must be an enumerable data value`); + } + entries.push([key, cloneValue(descriptor.value, `${path}.${key}`, depth + 1, budget)]); + } + entries.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + return Object.fromEntries(entries); +}; + +const cloneValue = ( + value: unknown, + path: string, + depth: number, + budget: JsonBudget +): DynamicsJsonValue => { + consumeNode(budget, depth, path); + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") return consumeString(value, path, budget); + if (typeof value === "number") { + if (!Number.isFinite(value)) throw new Error(`${path} must contain only finite numbers`); + return Object.is(value, -0) ? 0 : value; + } + if (Array.isArray(value)) return cloneArray(value, path, depth, budget); + if (typeof value === "object") return cloneObject(value, path, depth, budget); + throw new Error(`${path} must be JSON-compatible`); +}; + +export const cloneDynamicsJson = (value: unknown, path = "value"): DynamicsJsonValue => + cloneValue(value, path, 0, { codeUnits: 0, nodes: 0 }); + +export const cloneDynamicsJsonObject = (value: unknown, path = "value"): DynamicsJsonObject => { + const cloned = cloneDynamicsJson(value, path); + if (cloned === null || Array.isArray(cloned) || typeof cloned !== "object") { + throw new Error(`${path} must be an object`); + } + return cloned; +}; + +/** Injective for every accepted dynamics JSON value after the documented -0 normalization. */ +export const canonicalDynamicsJson = (value: unknown, path = "value"): string => + JSON.stringify(cloneDynamicsJson(value, path)); diff --git a/src/dynamics/cliSafety.test.ts b/src/dynamics/cliSafety.test.ts new file mode 100644 index 0000000..6c519ef --- /dev/null +++ b/src/dynamics/cliSafety.test.ts @@ -0,0 +1,92 @@ +import assert from "node:assert/strict"; +import { access, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { runCli } from "../cli/index.js"; + +const captureOutput = async (operation: () => Promise): Promise<{ code: number; stderr: string }> => { + const stderr: string[] = []; + const originalStderr = process.stderr.write; + const originalStdout = process.stdout.write; + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + process.stdout.write = (() => true) as typeof process.stdout.write; + try { + return { code: await operation(), stderr: stderr.join("") }; + } finally { + process.stderr.write = originalStderr; + process.stdout.write = originalStdout; + } +}; + +describe("dynamics CLI safety", () => { + it("validates without executing trusted code and makes legacy run fail closed", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "simfile-dynamics-cli-")); + try { + const systems = path.join(directory, "systems"); + const marker = path.join(directory, "module-executed"); + const simfilePath = path.join(directory, "Simfile"); + const out = path.join(directory, "run"); + await mkdir(systems); + await writeFile(path.join(systems, "unsafe.mjs"), ` +import { writeFileSync } from "node:fs"; +writeFileSync(${JSON.stringify(marker)}, "executed"); +export const createDynamicsProvider = () => ({}); +`, "utf8"); + await writeFile(simfilePath, ` +simfile_version: "0.1" +name: cli-dynamics +clock: + seed: cli-dynamics + tick: 10ms +dynamics: + module: ./systems/unsafe.mjs +`, "utf8"); + + const validation = await captureOutput(() => runCli(["validate", simfilePath])); + assert.equal(validation.code, 0); + await assert.rejects(access(marker)); + + const run = await captureOutput(() => runCli(["run", simfilePath, "--ticks", "1", "--out", out])); + assert.equal(run.code, 1); + assert.match(run.stderr, /unsupported external import: node:fs/u); + await assert.rejects(access(marker)); + await assert.rejects(access(out)); + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); + + it("rejects unsafe config and dynamics seeds during pure validation without importing", async () => { + const directory = await mkdtemp(path.join(tmpdir(), "simfile-dynamics-validate-")); + try { + const marker = path.join(directory, "module-executed"); + const simfilePath = path.join(directory, "Simfile.json"); + await mkdir(path.join(directory, "systems")); + await writeFile(path.join(directory, "systems", "unsafe.mjs"), ` +import { writeFileSync } from "node:fs"; +writeFileSync(${JSON.stringify(marker)}, "executed"); +export const createDynamicsProvider = () => ({}); +`, "utf8"); + for (const [document, expected] of [[ + `{"simfile_version":"0.1","name":"unsafe-config","clock":{"seed":"validate","tick":"1s"},"dynamics":{"module":"./systems/unsafe.mjs","config":{"constructor":1}}}`, + /safe dynamics JSON key/u + ], [ + `{"simfile_version":"0.1","name":"unsafe-seed","clock":{"seed":${JSON.stringify("x".repeat(257))},"tick":"1s"},"dynamics":{"module":"./systems/unsafe.mjs"}}`, + /clock\.seed.*256 code units/u + ]] as const) { + await writeFile(simfilePath, document, "utf8"); + const validation = await captureOutput(() => runCli(["validate", simfilePath])); + assert.equal(validation.code, 1); + assert.match(validation.stderr, expected); + await assert.rejects(access(marker)); + } + } finally { + await rm(directory, { force: true, recursive: true }); + } + }); +}); diff --git a/src/dynamics/commitmentOutcomes.test.ts b/src/dynamics/commitmentOutcomes.test.ts new file mode 100644 index 0000000..05312e2 --- /dev/null +++ b/src/dynamics/commitmentOutcomes.test.ts @@ -0,0 +1,129 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + parseDynamicsCommitmentOutcomeDraft, + parseDynamicsCommitmentOutcomeDrafts, +} from "./commitmentOutcomes.js"; +import { DYNAMICS_LIMITS } from "./limits.js"; +import { parseDynamicsStepResult } from "./validation.js"; + +const valid = () => ({ + commitment_id: "commitment:alpha:1", + declaration_action_sequence: 1, + outcome: "fulfilled", + participant: "object:participant.alpha", +}); + +test("parses the exact terminal commitment draft", () => { + assert.deepEqual(parseDynamicsCommitmentOutcomeDraft(valid()), valid()); + for (const mutate of [ + (value: Record) => { value.outcome = "pending"; }, + (value: Record) => { value.reason = "because"; }, + (value: Record) => { value.declaration_action_sequence = 0; }, + (value: Record) => { value.commitment_id = ""; }, + (value: Record) => { value.tick = 4; }, + (value: Record) => { value.provenance = "mechanical"; }, + ]) { + const value = valid() as Record; + mutate(value); + assert.throws(() => parseDynamicsCommitmentOutcomeDraft(value)); + } +}); + +test("rejects duplicate commitment ids within one step", () => { + assert.throws( + () => parseDynamicsCommitmentOutcomeDrafts([ + valid(), + { ...valid(), outcome: "expired" }, + ]), + /duplicate commitment id/u, + ); + assert.deepEqual( + parseDynamicsCommitmentOutcomeDrafts([ + valid(), + { ...valid(), commitment_id: "commitment:beta:2", outcome: "abandoned" }, + ]).map(({ outcome }) => outcome), + ["fulfilled", "abandoned"], + ); +}); + +test("a step result counts commitment outcomes against the per-tick event limit", () => { + const stepResult = (events: number, outcomes: number) => ({ + action_results: [], + commitment_outcomes: Array.from({ length: outcomes }, (_unused, index) => ({ + ...valid(), + commitment_id: `commitment:alpha:${index + 1}`, + declaration_action_sequence: index + 1, + })), + events: Array.from({ length: events }, () => ({ + cause_action_sequences: [], + kind: "probe", + payload: {}, + source: "object:participant.alpha", + target: "object:participant.alpha", + })), + tick: 0, + }); + const limit = DYNAMICS_LIMITS.events_per_tick; + assert.equal( + parseDynamicsStepResult(stepResult(limit - 2, 2), 0, [], 1) + .commitment_outcomes?.length, + 2, + ); + assert.throws( + () => parseDynamicsStepResult(stepResult(limit - 1, 2), 0, [], 1), + /events and commitment outcomes exceed the per-tick event limit/u, + ); +}); + +const addressed = () => ({ + commitment_id: "commitment:alpha:2", + counterparty: "object:participant.beta", + declaration_action_sequence: 2, + outcome: "matched", + participant: "object:participant.alpha", +}); + +test("admits a counterparty only where an addressed outcome allows one", () => { + assert.deepEqual(parseDynamicsCommitmentOutcomeDraft(addressed()), addressed()); + assert.deepEqual( + parseDynamicsCommitmentOutcomeDraft({ ...addressed(), outcome: "unmatched" }), + { ...addressed(), outcome: "unmatched" }, + ); + // Optional for an expired declaration: it may or may not have been addressed. + assert.deepEqual( + parseDynamicsCommitmentOutcomeDraft({ ...addressed(), outcome: "expired" }), + { ...addressed(), outcome: "expired" }, + ); + assert.deepEqual( + parseDynamicsCommitmentOutcomeDraft({ ...valid(), outcome: "expired" }), + { ...valid(), outcome: "expired" }, + ); +}); + +test("refuses a counterparty that contradicts the outcome kind", () => { + for (const outcome of ["fulfilled", "abandoned"]) { + assert.throws( + () => parseDynamicsCommitmentOutcomeDraft({ ...addressed(), outcome }), + /counterparty is not admitted/u, + ); + } + for (const outcome of ["matched", "unmatched"]) { + assert.throws( + () => parseDynamicsCommitmentOutcomeDraft({ ...valid(), outcome }), + /counterparty is required/u, + ); + } + assert.throws( + () => parseDynamicsCommitmentOutcomeDraft({ + ...addressed(), + counterparty: addressed().participant, + }), + /counterparty must not be the participant/u, + ); + assert.throws(() => parseDynamicsCommitmentOutcomeDraft({ + ...addressed(), + counterparty: "Not An Address", + })); +}); diff --git a/src/dynamics/commitmentOutcomes.ts b/src/dynamics/commitmentOutcomes.ts new file mode 100644 index 0000000..f1579c3 --- /dev/null +++ b/src/dynamics/commitmentOutcomes.ts @@ -0,0 +1,127 @@ +import { cloneDynamicsJsonObject } from "./canonicalJson.js"; +import { DYNAMICS_LIMITS } from "./limits.js"; +import type { + DynamicsCommitmentOutcomeDraft, + DynamicsCommitmentOutcomeStatus, +} from "./types.js"; + +const ADDRESS_PATTERN = /^[a-z][a-z0-9_-]*(?::[a-z][a-z0-9_.-]*)+$/u; +const IDENTIFIER_PATTERN = /^[a-z][a-z0-9_-]*(?::[a-z0-9][a-z0-9_.-]*)+$/u; +const OUTCOMES = new Set([ + "fulfilled", + "expired", + "abandoned", + "matched", + "unmatched", +]); +/** Addressed outcomes state whether the world matched a declared expectation. */ +const COUNTERPARTY_REQUIRED = new Set([ + "matched", + "unmatched", +]); +/** Unaddressed outcomes are a participant's own commitment, so none is named. */ +const COUNTERPARTY_FORBIDDEN = new Set([ + "fulfilled", + "abandoned", +]); + +const exactKeys = ( + value: Record, + expected: readonly string[], + path: string, +): void => { + const allowed = new Set(expected); + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string" || !allowed.has(key)) { + throw new Error(`${path} contains unknown field ${String(key)}`); + } + } +}; + +const boundedCanonical = ( + value: unknown, + pattern: RegExp, + path: string, +): string => { + if (typeof value !== "string" || value.length === 0 + || value.length > DYNAMICS_LIMITS.identifier_code_units + || value !== value.trim() || !pattern.test(value)) { + throw new Error(`${path} must be a canonical identifier`); + } + return value; +}; + +export const parseDynamicsCommitmentOutcomeDraft = ( + value: unknown, + path = "dynamics commitment outcome", +): DynamicsCommitmentOutcomeDraft => { + const record = cloneDynamicsJsonObject(value, path); + exactKeys(record, [ + "commitment_id", + "counterparty", + "declaration_action_sequence", + "outcome", + "participant", + ], path); + if (!Number.isSafeInteger(record.declaration_action_sequence) + || (record.declaration_action_sequence as number) <= 0) { + throw new Error(`${path}.declaration_action_sequence must be positive`); + } + const outcome = record.outcome as DynamicsCommitmentOutcomeStatus; + if (!OUTCOMES.has(outcome)) { + throw new Error(`${path}.outcome is invalid`); + } + const addressed = Object.hasOwn(record, "counterparty"); + if (addressed && COUNTERPARTY_FORBIDDEN.has(outcome)) { + throw new Error(`${path}.counterparty is not admitted for ${outcome}`); + } + if (!addressed && COUNTERPARTY_REQUIRED.has(outcome)) { + throw new Error(`${path}.counterparty is required for ${outcome}`); + } + const participant = boundedCanonical( + record.participant, + ADDRESS_PATTERN, + `${path}.participant`, + ); + const counterparty = addressed + ? boundedCanonical( + record.counterparty, + ADDRESS_PATTERN, + `${path}.counterparty`, + ) + : undefined; + if (counterparty === participant) { + throw new Error(`${path}.counterparty must not be the participant`); + } + return { + commitment_id: boundedCanonical( + record.commitment_id, + IDENTIFIER_PATTERN, + `${path}.commitment_id`, + ), + ...(counterparty === undefined ? {} : { counterparty }), + declaration_action_sequence: record.declaration_action_sequence as number, + outcome, + participant, + }; +}; + +export const parseDynamicsCommitmentOutcomeDrafts = ( + value: unknown, + path = "dynamics step result.commitment_outcomes", +): DynamicsCommitmentOutcomeDraft[] => { + if (!Array.isArray(value) + || value.length > DYNAMICS_LIMITS.events_per_tick) { + throw new Error(`${path} exceeds the per-tick outcome limit`); + } + const outcomes = value.map((entry, index) => + parseDynamicsCommitmentOutcomeDraft(entry, `${path}[${index}]`)); + const ids = new Set(); + for (const outcome of outcomes) { + if (ids.has(outcome.commitment_id)) { + throw new Error(`${path} contains duplicate commitment id ${outcome.commitment_id}`); + } + ids.add(outcome.commitment_id); + } + return outcomes; +}; diff --git a/src/dynamics/index.ts b/src/dynamics/index.ts new file mode 100644 index 0000000..66c9d88 --- /dev/null +++ b/src/dynamics/index.ts @@ -0,0 +1,36 @@ +export * from "./load.js"; +export type { DynamicsSession } from "./session.js"; +export { + canonicalDynamicsJson, +} from "./canonicalJson.js"; +export { + parseDynamicsActionAttempt, + parseDynamicsProvenance +} from "./validation.js"; +export { + prepareDynamicsBuild, + type PreparedDynamicsBuild +} from "./build.js"; +export { + persistDynamicsBuild, + type DynamicsBuildArtifactLifecycle +} from "./buildLoad.js"; +export { + createDynamicsBuildReceipt +} from "./buildReceipt.js"; +export { + parseDynamicsSessionSnapshot +} from "./snapshotValidation.js"; +export { + DYNAMICS_RUN_ACTION_SOURCE_VERSION +} from "./runActionSource.js"; +export type { + DynamicsRunActionSourceDeclaration, + DynamicsRunActionSourceFactory, + DynamicsRunActionSourceInitialization, + DynamicsRunActionSourceTick, + DynamicsRunControllerAction +} from "./runActionSource.js"; +export * from "./limits.js"; +export * from "./types.js"; +export * from "../world-surface/index.js"; diff --git a/src/dynamics/integration.test.ts b/src/dynamics/integration.test.ts new file mode 100644 index 0000000..145e33b --- /dev/null +++ b/src/dynamics/integration.test.ts @@ -0,0 +1,67 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { loadDynamicsSession } from "./load.js"; +import { + createDynamicsTestProject, + removeDynamicsTestProject, + tinyProviderSource +} from "./testSupport.test-helper.js"; + +describe("dynamics provider integration contract", () => { + it("validates, deeply freezes, and exposes metadata through DynamicsSession", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + const integration = session.integration; + assert.deepEqual(integration, { + accepted_actions: ["increment"], + model: "counter" + }); + assert.equal(Object.isFrozen(integration), true); + assert.equal(Object.isFrozen(integration.accepted_actions), true); + assert.throws(() => { + (integration.accepted_actions as string[]).push("forge"); + }, TypeError); + assert.deepEqual(session.integration, { + accepted_actions: ["increment"], + model: "counter" + }); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("rejects unsafe metadata before provider initialization", async () => { + const source = tinyProviderSource().replace( + 'integration: { accepted_actions: ["increment"], model: "counter" },', + "integration: { unsafe: Infinity }," + ); + const project = await createDynamicsTestProject(source); + try { + await assert.rejects( + loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }), + /provider\.integration.*finite/u + ); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("normalizes an omitted optional contract to an immutable empty object", async () => { + const source = tinyProviderSource().replace( + ' integration: { accepted_actions: ["increment"], model: "counter" },\n', + "" + ); + const project = await createDynamicsTestProject(source); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + assert.deepEqual(session.integration, {}); + assert.equal(Object.isFrozen(session.integration), true); + } finally { + await removeDynamicsTestProject(project); + } + }); +}); diff --git a/src/dynamics/limits.ts b/src/dynamics/limits.ts new file mode 100644 index 0000000..ff59eb6 --- /dev/null +++ b/src/dynamics/limits.ts @@ -0,0 +1,56 @@ +/** Hard v1 resource ceilings enforced by the host before accepting provider data. */ +export const DYNAMICS_LIMITS = Object.freeze({ + actions_per_tick: 128, + causes_per_event: 128, + events_per_tick: 256, + identifier_code_units: 256, + json_code_units: 65_536, + json_depth: 24, + json_nodes: 4_096, + json_string_length: 16_384, + message_code_units: 4_096, + observation_channels: 256, + observation_components_per_channel: 256, + retained_action_code_units: 1_048_576, + retained_action_records: 10_000, + sense_grants: 128, + spatial_objects: 256 +}); + +const SHA256_CODE_UNITS = 64; +const JSON_ESCAPE_CODE_UNITS = "\\u0000".length; +const MAXIMUM_INTEGER_CODE_UNITS = String(Number.MAX_SAFE_INTEGER).length; +const maximumJsonStringCodeUnits = (codeUnits: number): number => + 2 + codeUnits * JSON_ESCAPE_CODE_UNITS; +const queuedIngressShapeCodeUnits = JSON.stringify({ + act_id: "", + at_tick: 0, + attempt_sha256: "", + principal_id: "", + retained_at_tick: 0, + receipt: { act_id: "", apply_tick: 0, queued: true, sequence: 0 } +}).length + + maximumJsonStringCodeUnits(DYNAMICS_LIMITS.identifier_code_units) * 3 - 6 + + SHA256_CODE_UNITS + + (MAXIMUM_INTEGER_CODE_UNITS - 1) * 4; +const rejectedIngressShapeCodeUnits = JSON.stringify({ + act_id: "", + at_tick: 0, + attempt_sha256: "", + principal_id: "", + receipt: { act_id: "", apply_tick: 0, code: "wrong_tick", queued: false } +}).length + + maximumJsonStringCodeUnits(DYNAMICS_LIMITS.identifier_code_units) * 3 - 6 + + SHA256_CODE_UNITS + + (MAXIMUM_INTEGER_CODE_UNITS - 1) * 2; +const RETAINED_INGRESS_RECORD_CODE_UNITS = Math.max( + queuedIngressShapeCodeUnits, + rejectedIngressShapeCodeUnits +); + +export const DYNAMICS_ACTION_RETENTION_LIMITS = Object.freeze({ + code_units: DYNAMICS_LIMITS.actions_per_tick + * RETAINED_INGRESS_RECORD_CODE_UNITS, + record_code_units: RETAINED_INGRESS_RECORD_CODE_UNITS, + records: DYNAMICS_LIMITS.actions_per_tick +}); diff --git a/src/dynamics/load.test-helper.ts b/src/dynamics/load.test-helper.ts new file mode 100644 index 0000000..e91a093 --- /dev/null +++ b/src/dynamics/load.test-helper.ts @@ -0,0 +1,121 @@ +import { access, mkdir, mkdtemp, realpath, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { type TestContext } from "node:test"; + +import { parseSimfileSource } from "../schema/parse.js"; +import { type Simfile } from "../schema/model.js"; +import { type LoadDynamicsSessionOptions } from "./load.js"; + +export interface LoadTestProject { + readonly directory: string; + readonly evidenceRoot: string; + readonly modulePath: string; + readonly options: LoadDynamicsSessionOptions; + readonly scratchRoot: string; + readonly simfile: Simfile; + readonly simfilePath: string; +} + +export interface CreateLoadTestProjectOptions { + readonly configSource?: string; + readonly extension?: ".mjs" | ".ts"; + readonly source: string; +} + +export const providerFactorySource = ( + extension: ".mjs" | ".ts" = ".mjs", + factoryPrelude = "" +): string => { + const annotation = extension === ".ts" + ? 'import type { DynamicsProviderModule } from "simfile/dynamics";\n' + + 'export const createDynamicsProvider: DynamicsProviderModule["createDynamicsProvider"] =' + : '/** @type {import("simfile/dynamics").DynamicsProviderModule["createDynamicsProvider"]} */\n' + + "export const createDynamicsProvider ="; + return `${annotation} () => { + ${factoryPrelude} + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", + dependencies: { "tiny-math": "1.0.0" }, + id: "sealed-counter", + integration: { model: "sealed-counter" }, + version: "1.0.0", + state_schema_version: "counter.v1", + initialize(context) { + state = { value: typeof context.config.start === "number" ? context.config.start : 0 }; + }, + observe() { return { channels: [] }; }, + restore(snapshot) { + if (typeof snapshot !== "object" || snapshot === null || Array.isArray(snapshot)) { + throw new Error("invalid fixture snapshot"); + } + state = { value: typeof snapshot.value === "number" ? snapshot.value : 0 }; + }, + snapshot() { return { ...state }; }, + step(input) { + return { + action_results: input.actions.map((action) => ({ accepted: true, sequence: action.sequence })), + events: [], + tick: input.tick + }; + } + }; +}; +`; +}; + +export const createLoadTestProject = async ( + testContext: TestContext, + input: CreateLoadTestProjectOptions +): Promise => { + const temporaryRoot = await realpath(os.tmpdir()); + const directory = await mkdtemp(path.join(temporaryRoot, "simfile-load-project-")); + const scratchRoot = await mkdtemp(path.join(temporaryRoot, "simfile-load-scratch-")); + const evidenceRoot = await mkdtemp(path.join(temporaryRoot, "simfile-load-evidence-")); + testContext.after(async () => { + await Promise.all([directory, scratchRoot, evidenceRoot].map((root) => + rm(root, { force: true, recursive: true }))); + }); + const extension = input.extension ?? ".mjs"; + const moduleReference = `./systems/provider${extension}` as const; + const modulePath = path.join(directory, "systems", `provider${extension}`); + const simfilePath = path.join(directory, "Simfile"); + const simfileSource = ` +simfile_version: "0.1" +name: load-test +clock: + seed: load-seed + tick: 20ms + sim_per_tick: 0.5s +dynamics: + module: ${moduleReference} + config: + ${(input.configSource ?? "start: 2").replaceAll("\n", "\n ")} +`; + await mkdir(path.dirname(modulePath), { recursive: true }); + await writeFile(modulePath, input.source, "utf8"); + await writeFile(simfilePath, simfileSource, "utf8"); + return { + directory, + evidenceRoot, + modulePath, + options: { + artifactLifecycle: { evidenceRoot, scratchRoot }, + simfilePath + }, + scratchRoot, + simfile: parseSimfileSource(simfileSource, { path: simfilePath }).simfile, + simfilePath + }; +}; + +export const assertPathMissing = async (fileName: string): Promise => { + try { + await access(fileName); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + throw new Error(`expected path to be absent: ${fileName}`); +}; diff --git a/src/dynamics/load.test.ts b/src/dynamics/load.test.ts new file mode 100644 index 0000000..3afbe29 --- /dev/null +++ b/src/dynamics/load.test.ts @@ -0,0 +1,198 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { writeFileSync } from "node:fs"; +import { access, readFile, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { loadDynamicsSession } from "./load.js"; +import { + assertPathMissing, + createLoadTestProject, + providerFactorySource +} from "./load.test-helper.js"; + +const evidencePaths = ( + evidenceRoot: string, + artifactSha256: string +): { artifact: string; receipt: string } => ({ + artifact: path.join( + evidenceRoot, + "dynamics", + `sha256-${artifactSha256}`, + "provider.mjs" + ), + receipt: path.join(evidenceRoot, "dynamics", "build-receipt.json") +}); + +describe("loadDynamicsSession sealed artifact path", () => { + for (const extension of [".mjs", ".ts"] as const) { + it(`loads a sealed ${extension} provider and records artifact identity separately`, async (t) => { + const project = await createLoadTestProject(t, { + extension, + source: providerFactorySource(extension) + }); + const authoredBytes = await readFile(project.modulePath); + const session = await loadDynamicsSession(project.simfile, project.options); + assert.ok(session); + assert.deepEqual(session.provenance, { + api_version: "simfile.dynamics-provider.v1", + config_sha256: createHash("sha256").update('{"start":2}').digest("hex"), + module: `./systems/provider${extension}`, + module_sha256: session.buildReceipt.payload.artifact_sha256, + node_version: process.version, + numeric_model: "ieee754-binary64", + provider_dependencies: { "tiny-math": "1.0.0" }, + provider_id: "sealed-counter", + provider_version: "1.0.0", + state_schema_version: "counter.v1" + }); + assert.notEqual( + session.provenance.module_sha256, + createHash("sha256").update(authoredBytes).digest("hex") + ); + assert.equal(Object.isFrozen(session.buildReceipt), true); + assert.equal(Object.isFrozen(session.buildReceipt.payload), true); + assert.equal("buildReceipt" in session.provenance, false); + assert.equal("buildReceipt" in session.snapshot(), false); + assert.equal(JSON.stringify(session.buildReceipt).includes(project.directory), false); + + const evidence = evidencePaths( + project.evidenceRoot, + session.provenance.module_sha256 + ); + assert.equal( + createHash("sha256").update(await readFile(evidence.artifact)).digest("hex"), + session.buildReceipt.payload.artifact_sha256 + ); + assert.deepEqual( + [...await readFile(evidence.receipt)], + session.buildReceipt.receiptBytes + ); + await assertPathMissing(path.join(project.scratchRoot, "dynamics")); + }); + } + + it("hashes canonical config and changes artifact identity with authored source", async (t) => { + const project = await createLoadTestProject(t, { + source: providerFactorySource(".mjs") + }); + const firstConfig = { + ...project.simfile, + dynamics: { module: "./systems/provider.mjs", config: { b: 2, a: 1 } } + }; + const secondConfig = { + ...project.simfile, + dynamics: { module: "./systems/provider.mjs", config: { a: 1, b: 2 } } + }; + const automatic = { simfilePath: project.simfilePath }; + const first = await loadDynamicsSession(firstConfig, automatic); + const second = await loadDynamicsSession(secondConfig, automatic); + assert.ok(first && second); + assert.equal(first.provenance.config_sha256, second.provenance.config_sha256); + assert.equal(first.provenance.module_sha256, second.provenance.module_sha256); + + await writeFile( + project.modulePath, + providerFactorySource(".mjs", "const sourceRevision = 2; void sourceRevision;"), + "utf8" + ); + const reloaded = await loadDynamicsSession(project.simfile, automatic); + assert.ok(reloaded); + assert.notEqual(reloaded.provenance.module_sha256, first.provenance.module_sha256); + }); + + it("re-evaluates the whole bundled graph for every session", async (t) => { + const project = await createLoadTestProject(t, { + source: [ + 'import { nextCall } from "./dependency.mjs";', + '/** @type {import("simfile/dynamics").DynamicsProviderModule["createDynamicsProvider"]} */', + "export const createDynamicsProvider = () => {", + " const factoryCall = nextCall();", + " let state = { factory_call: factoryCall };", + " return {", + ' api_version: "simfile.dynamics-provider.v1", id: "graph-probe",', + ' version: "1", state_schema_version: "v1",', + " initialize() {}, observe() { return { channels: [] }; },", + " restore(snapshot) {", + ' if (typeof snapshot !== "object" || snapshot === null || Array.isArray(snapshot)) throw new Error("bad");', + ' state = { factory_call: typeof snapshot.factory_call === "number" ? snapshot.factory_call : 0 };', + " },", + " snapshot() { return { ...state }; },", + " step(input) { return { action_results: [], events: [], tick: input.tick }; }", + " };", + "};" + ].join("\n") + }); + await writeFile( + path.join(project.directory, "systems", "dependency.mjs"), + "let calls = 0;\nexport const nextCall = () => { calls += 1; return calls; };\n", + "utf8" + ); + const automatic = { simfilePath: project.simfilePath }; + const first = await loadDynamicsSession(project.simfile, automatic); + const second = await loadDynamicsSession(project.simfile, automatic); + assert.ok(first && second); + assert.deepEqual(first.snapshot().provider_state, { factory_call: 1 }); + assert.deepEqual(second.snapshot().provider_state, { factory_call: 1 }); + assert.deepEqual(first.provenance, second.provenance); + }); + + it("rejects missing, invalid, and asynchronous named factories after preserving evidence", async (t) => { + for (const [source, message] of [ + ["export default () => ({});\n", /named createDynamicsProvider/u], + ["export const createDynamicsProvider = 3;\n", /named createDynamicsProvider/u], + ["export const createDynamicsProvider = () => null;\n", /must return an object/u], + ["export const createDynamicsProvider = async () => ({});\n", /must be synchronous/u] + ] as const) { + const project = await createLoadTestProject(t, { source }); + await assert.rejects( + loadDynamicsSession(project.simfile, project.options), + message + ); + await assertPathMissing(path.join(project.scratchRoot, "dynamics")); + await access(path.join(project.evidenceRoot, "dynamics", "build-receipt.json")); + } + }); + + it("rejects invalid provider output and cleans caller scratch", async (t) => { + const project = await createLoadTestProject(t, { + source: [ + '/** @type {import("simfile/dynamics").DynamicsProviderModule["createDynamicsProvider"]} */', + "export const createDynamicsProvider = () => ({", + ' api_version: "simfile.dynamics-provider.v1",', + ' id: "bad-snapshot", version: "1", state_schema_version: "v1",', + " initialize() {}, observe() { return { channels: [] }; }, restore() {},", + " snapshot() { return /** @type {any} */ (new Date(0)); },", + " step(input) { return { action_results: [], events: [], tick: input.tick }; }", + "});" + ].join("\n") + }); + await assert.rejects( + loadDynamicsSession(project.simfile, project.options), + /JSON-compatible|plain JSON objects/u + ); + await assertPathMissing(path.join(project.scratchRoot, "dynamics")); + }); + + it("detects final source drift before invoking the factory", async (t) => { + const hookKey = "__simfileLoadMutateAuthoredSource"; + const project = await createLoadTestProject(t, { + source: [ + `const mutateSource = /** @type {any} */ (globalThis)[${JSON.stringify(hookKey)}];`, + 'if (typeof mutateSource !== "function") throw new Error("MUTATION_HOOK_MISSING");', + "mutateSource();", + providerFactorySource(".mjs", 'throw new Error("FACTORY_RAN");') + ].join("\n") + }); + (globalThis as Record)[hookKey] = () => + writeFileSync(project.modulePath, "export const drifted = true;\n", "utf8"); + t.after(() => { delete (globalThis as Record)[hookKey]; }); + await assert.rejects( + loadDynamicsSession(project.simfile, project.options), + /prepared project descriptor mismatch/u + ); + await assertPathMissing(path.join(project.scratchRoot, "dynamics")); + await access(path.join(project.evidenceRoot, "dynamics", "build-receipt.json")); + }); +}); diff --git a/src/dynamics/load.ts b/src/dynamics/load.ts new file mode 100644 index 0000000..1fe4195 --- /dev/null +++ b/src/dynamics/load.ts @@ -0,0 +1,26 @@ +import type { Simfile } from "../schema/model.js"; +import { + loadDynamicsCore, + type LoadDynamicsArtifactLifecycleOptions, + type LoadDynamicsSessionOptions +} from "./loadCore.js"; +import type { DynamicsSession } from "./session.js"; + +export type { DynamicsBuildReceipt } from "./buildReceipt.js"; +export type { + LoadDynamicsArtifactLifecycleOptions, + LoadDynamicsSessionOptions +} from "./loadCore.js"; + +/** + * Prepares, receipts, persists, verifies, and imports one trusted authored + * provider as a sealed bundle. The scratch lifecycle is removed before this + * function settles; a requested evidence pair remains caller-owned. + */ +export const loadDynamicsSession = async ( + simfile: Simfile, + options: LoadDynamicsSessionOptions +): Promise => { + const loaded = await loadDynamicsCore(simfile, options); + return loaded?.session; +}; diff --git a/src/dynamics/loadCore.ts b/src/dynamics/loadCore.ts new file mode 100644 index 0000000..551a119 --- /dev/null +++ b/src/dynamics/loadCore.ts @@ -0,0 +1,200 @@ +import { createHash } from "node:crypto"; +import { mkdtemp, realpath, rm } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; + +import { parseClockSpec } from "../runtime/clock.js"; +import type { Simfile } from "../schema/model.js"; +import { prepareDynamicsBuild } from "./build.js"; +import { createDynamicsBuildReceipt } from "./buildReceipt.js"; +import { + persistDynamicsBuild, + type DynamicsBuildArtifactLifecycle +} from "./buildLoad.js"; +import { + canonicalDynamicsJson, + cloneDynamicsJsonObject +} from "./canonicalJson.js"; +import type { + DynamicsRunActionSourceInitialization +} from "./runActionSource.js"; +import { createDynamicsSession, type DynamicsSession } from "./session.js"; +import { + DYNAMICS_PROVIDER_API_VERSION, + type DynamicsJsonValue, + type DynamicsProvider, + type DynamicsProviderModule, + type DynamicsProvenance +} from "./types.js"; +import { + assertDynamicsProvider, + parseDynamicsSeed, + providerDependencies +} from "./validation.js"; + +export interface LoadDynamicsArtifactLifecycleOptions { + readonly evidenceRoot?: string; + readonly scratchRoot: string; +} + +export interface LoadDynamicsSessionOptions { + artifactLifecycle?: LoadDynamicsArtifactLifecycleOptions; + seed?: string; + simfilePath: string; +} + +export interface LoadDynamicsCoreResult { + readonly extension: Extension; + readonly session: DynamicsSession; +} + +type LoadedExtension = ( + loaded: Record, + initialization: DynamicsRunActionSourceInitialization +) => Extension; + +const sha256 = (value: string): string => + createHash("sha256").update(value).digest("hex"); + +const isPromiseLike = (value: unknown): value is PromiseLike => + typeof value === "object" && value !== null + && typeof (value as { then?: unknown }).then === "function"; + +const freezeJson = (value: Value): Value => { + if (Array.isArray(value)) { + for (const entry of value) freezeJson(entry); + } else if (value !== null && typeof value === "object") { + for (const entry of Object.values(value)) freezeJson(entry); + } + return Object.freeze(value); +}; + +const createProvider = (loaded: Record): DynamicsProvider => { + const factory = loaded.createDynamicsProvider; + if (typeof factory !== "function") { + throw new Error("dynamics module must export named createDynamicsProvider()"); + } + const provider = (factory as DynamicsProviderModule["createDynamicsProvider"])(); + if (isPromiseLike(provider)) { + throw new Error("createDynamicsProvider() must be synchronous"); + } + assertDynamicsProvider(provider); + return provider; +}; + +const createOwnedScratchRoot = async (): Promise => + mkdtemp(path.join(await realpath(os.tmpdir()), "simfile-dynamics-load-")); + +const throwOutcome = ( + failure: unknown, + cleanupFailures: readonly unknown[] +): never => { + if (cleanupFailures.length === 0) throw failure; + throw new AggregateError( + [failure, ...cleanupFailures], + "dynamics loading and artifact cleanup both failed" + ); +}; + +export const loadDynamicsCore = async ( + simfile: Simfile, + options: LoadDynamicsSessionOptions, + loadExtension?: LoadedExtension +): Promise | undefined> => { + if (!simfile.dynamics) return undefined; + + const seed = parseDynamicsSeed( + options.seed === undefined ? simfile.clock.seed : options.seed + ); + const clock = parseClockSpec(simfile.clock); + const config = cloneDynamicsJsonObject( + simfile.dynamics.config, + "dynamics config" + ); + const configSha256 = sha256(canonicalDynamicsJson(config)); + const requestedSimfilePath = path.resolve(options.simfilePath); + let ownedScratchRoot: string | undefined; + let lifecycle: DynamicsBuildArtifactLifecycle | undefined; + let result: LoadDynamicsCoreResult | undefined; + let failed = false; + let failure: unknown; + try { + const prepared = await prepareDynamicsBuild( + requestedSimfilePath, + simfile.dynamics.module + ); + const absoluteSimfilePath = await realpath(requestedSimfilePath); + const buildReceipt = await createDynamicsBuildReceipt( + absoluteSimfilePath, + prepared + ); + const scratchRoot = options.artifactLifecycle?.scratchRoot + ?? (ownedScratchRoot = await createOwnedScratchRoot()); + lifecycle = await persistDynamicsBuild({ + absoluteSimfilePath, + evidenceRoot: options.artifactLifecycle?.evidenceRoot, + prepared, + receipt: buildReceipt, + scratchRoot + }); + const loaded = await lifecycle.importArtifact(); + await lifecycle.verify(); + const provider = createProvider(loaded); + const provenance: DynamicsProvenance = { + api_version: DYNAMICS_PROVIDER_API_VERSION, + config_sha256: configSha256, + module: prepared.module, + module_sha256: prepared.artifactSha256, + node_version: process.version, + numeric_model: "ieee754-binary64", + provider_dependencies: providerDependencies(provider), + provider_id: provider.id, + provider_version: provider.version, + state_schema_version: provider.state_schema_version + }; + const session = createDynamicsSession(provider, { + buildReceipt, + config, + provenance, + seed, + simSecondsPerTick: clock.simPerTickSeconds + }); + const initialization = Object.freeze({ + config: freezeJson(cloneDynamicsJsonObject(config, "dynamics config")), + seed, + sim_seconds_per_tick: clock.simPerTickSeconds + }); + result = { + extension: loadExtension?.(loaded, initialization) as Extension, + session + }; + } catch (error) { + failed = true; + failure = error; + } + + const cleanupFailures: unknown[] = []; + if (lifecycle) { + try { + await lifecycle.cleanup(); + } catch (error) { + cleanupFailures.push(error); + } + } + if (ownedScratchRoot) { + try { + await rm(ownedScratchRoot, { force: true, recursive: true }); + } catch (error) { + cleanupFailures.push(error); + } + } + if (failed) throwOutcome(failure, cleanupFailures); + if (cleanupFailures.length > 0) { + throw new AggregateError( + cleanupFailures, + "dynamics artifact cleanup failed" + ); + } + if (!result) throw new Error("dynamics loading completed without a session"); + return result; +}; diff --git a/src/dynamics/loadPreflight.test.ts b/src/dynamics/loadPreflight.test.ts new file mode 100644 index 0000000..9604ff0 --- /dev/null +++ b/src/dynamics/loadPreflight.test.ts @@ -0,0 +1,150 @@ +import assert from "node:assert/strict"; +import { mkdir, readFile, symlink, writeFile } from "node:fs/promises"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import type { Simfile } from "../schema/model.js"; +import { parseSimfileSource } from "../schema/parse.js"; +import { loadDynamicsSession } from "./load.js"; +import { + createDynamicsTestProject, + removeDynamicsTestProject +} from "./testSupport.test-helper.js"; + +describe("loadDynamicsSession preflight", () => { + it("rejects unsafe and symlinked authored paths before target evaluation", async () => { + const project = await createDynamicsTestProject(); + try { + for (const module of [ + "../target.mjs", + "/tmp/target.mjs", + "./systems/../target.mjs", + "./systems/not portable.mjs", + "./systems/target.js", + `./systems/${"x".repeat(256)}.mjs`, + "file:///tmp/target.mjs" + ]) { + await assert.rejects(loadDynamicsSession({ + ...project.simfile, + dynamics: { config: {}, module } + } as Simfile, { simfilePath: project.simfilePath }), /portable|path segments/u); + } + + const marker = "__simfileLinkedProviderExecuted"; + delete (globalThis as Record)[marker]; + const target = path.join(project.directory, "target.mjs"); + await writeFile(target, `globalThis[${JSON.stringify(marker)}] = true;\n`, "utf8"); + const leaf = path.join(project.directory, "systems", "linked.mjs"); + await symlink(target, leaf); + await assert.rejects(loadDynamicsSession({ + ...project.simfile, + dynamics: { module: "./systems/linked.mjs", config: {} } + }, { simfilePath: project.simfilePath }), /symlink/u); + + const realDirectory = path.join(project.directory, "real-systems"); + await mkdir(realDirectory); + await writeFile(path.join(realDirectory, "linked.mjs"), await readFile(target)); + await symlink(realDirectory, path.join(project.directory, "linked-systems")); + await assert.rejects(loadDynamicsSession({ + ...project.simfile, + dynamics: { module: "./linked-systems/linked.mjs", config: {} } + }, { simfilePath: project.simfilePath }), /symlink/u); + assert.equal((globalThis as Record)[marker], undefined); + delete (globalThis as Record)[marker]; + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("validates config, effective seed, and clock before preparing provider code", async () => { + const project = await createDynamicsTestProject(); + try { + for (const config of [(() => { + const sparse: unknown[] = []; + sparse[1] = 1; + return { sparse }; + })(), JSON.parse('{"__proto__":{"polluted":true}}')]) { + await assert.rejects(loadDynamicsSession({ + ...project.simfile, + dynamics: { module: "./systems/tiny.mjs", config } + } as Simfile, { simfilePath: project.simfilePath }), /sparse arrays|safe dynamics JSON key/u); + } + await assert.rejects(loadDynamicsSession(project.simfile, { + seed: "x".repeat(257), + simfilePath: project.simfilePath + }), /seed exceeds/u); + for (const seed of [null, 42, {}]) { + await assert.rejects(loadDynamicsSession(project.simfile, { + seed, + simfilePath: project.simfilePath + } as unknown as Parameters[1]), /seed must be/u); + } + for (const simPerTick of ["", null, false, 0, `1${"0".repeat(307)}w`]) { + await assert.rejects(loadDynamicsSession({ + ...project.simfile, + clock: { ...project.simfile.clock, sim_per_tick: simPerTick } + } as unknown as Simfile, { simfilePath: project.simfilePath }), /duration|positive and finite/u); + } + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("normalizes negative zero before config hashing and initialization", async () => { + const source = ` +export const createDynamicsProvider = () => { + let state = { zero: 1 }; + return { + api_version: "simfile.dynamics-provider.v1", + id: "zero-normalizer", + version: "1", + state_schema_version: "v1", + initialize(context) { + state = { zero: typeof context.config.zero === "number" ? context.config.zero : 0 }; + }, + observe() { return { channels: [] }; }, + restore(snapshot) { + if (typeof snapshot !== "object" || snapshot === null || Array.isArray(snapshot)) { + throw new Error("invalid snapshot"); + } + state = { zero: typeof snapshot.zero === "number" ? snapshot.zero : 0 }; + }, + snapshot() { return { ...state }; }, + step(input) { return { action_results: [], events: [], tick: input.tick }; } + }; +}; +`; + const project = await createDynamicsTestProject( + source, + "zero: -0" + ); + try { + const negative = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + const positive = parseSimfileSource( + '{"simfile_version":"0.1","name":"zero","clock":{"seed":"load","tick":"20ms","sim_per_tick":"0.5s"},"dynamics":{"module":"./systems/tiny.mjs","config":{"zero":0}}}', + { path: project.simfilePath } + ).simfile; + const right = await loadDynamicsSession(positive, { simfilePath: project.simfilePath }); + assert.ok(negative && right); + assert.equal(negative.provenance.config_sha256, right.provenance.config_sha256); + const state = negative.snapshot().provider_state as { zero: number }; + assert.equal(Object.is(state.zero, -0), false); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("returns undefined without inspecting invalid options when dynamics is absent", async () => { + const project = await createDynamicsTestProject(); + try { + const { dynamics: _ignored, ...withoutDynamics } = project.simfile; + withoutDynamics.clock.sim_per_tick = `1${"0".repeat(307)}w`; + assert.equal(await loadDynamicsSession(withoutDynamics, { + seed: "x".repeat(257), + simfilePath: project.simfilePath + }), undefined); + } finally { + await removeDynamicsTestProject(project); + } + }); +}); diff --git a/src/dynamics/loadRunActionSource.test.ts b/src/dynamics/loadRunActionSource.test.ts new file mode 100644 index 0000000..c3717e6 --- /dev/null +++ b/src/dynamics/loadRunActionSource.test.ts @@ -0,0 +1,160 @@ +import assert from "node:assert/strict"; +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; + +import type { Simfile } from "../schema/model.js"; +import { loadDynamicsRunActionSource } from "./loadRunActionSource.js"; +import { + assertPathMissing, + createLoadTestProject, + providerFactorySource +} from "./load.test-helper.js"; + +const withGrants = (simfile: Simfile): Simfile => ({ + ...simfile, + world: { + id: "counter" as NonNullable["id"], + grants: { + blue: { + entity: "entity:blue", + senses: [], + affordances: [] + } + } + } as unknown as NonNullable +}); + +const sourceExport = (body: string): string => ` +/** @type {import("simfile/dynamics").DynamicsRunActionSourceFactory} */ +export const createDynamicsRunActionSource = ${body}; +`; + +for (const extension of [".mjs", ".ts"] as const) { + test(`loads a sealed ${extension} provider and action source together`, async (t) => { + const project = await createLoadTestProject(t, { + extension, + source: providerFactorySource(extension) + sourceExport(`() => ({ + id: "sealed-script", + live_acceptance: false, + onTick() {}, + participants: ["blue"], + provenance: "scripted", + version: "simfile.dynamics-run-action-source.v1" +})`) + }); + const loaded = await loadDynamicsRunActionSource( + withGrants(project.simfile), + project.options + ); + assert.ok(loaded?.actionSource); + assert.equal(loaded.actionSource.id, "sealed-script"); + assert.equal( + loaded.session.provenance.module_sha256, + loaded.session.buildReceipt.payload.artifact_sha256 + ); + const artifact = path.join( + project.evidenceRoot, + loaded.session.buildReceipt.payload.artifact_path + ); + assert.equal((await readFile(artifact, "utf8")).includes("sealed-script"), true); + await access(path.join( + project.evidenceRoot, + "dynamics", + "build-receipt.json" + )); + await assertPathMissing(path.join(project.scratchRoot, "dynamics")); + }); +} + +test("treats an absent export or synchronous undefined as no source", async (t) => { + const absent = await createLoadTestProject(t, { + source: providerFactorySource() + }); + const omitted = await loadDynamicsRunActionSource( + absent.simfile, + absent.options + ); + assert.ok(omitted); + assert.equal(omitted.actionSource, undefined); + + const disabled = await createLoadTestProject(t, { + source: providerFactorySource() + + sourceExport("() => undefined") + }); + const returned = await loadDynamicsRunActionSource( + disabled.simfile, + disabled.options + ); + assert.ok(returned); + assert.equal(returned.actionSource, undefined); +}); + +test("rejects an asynchronous factory and cleans scratch after preserving evidence", async (t) => { + const project = await createLoadTestProject(t, { + source: providerFactorySource() + + ` +/** @type {any} */ +export const createDynamicsRunActionSource = async () => undefined; +` + }); + await assert.rejects( + loadDynamicsRunActionSource(project.simfile, project.options), + /createDynamicsRunActionSource\(\) must be synchronous/u + ); + await assertPathMissing(path.join(project.scratchRoot, "dynamics")); + await access(path.join( + project.evidenceRoot, + "dynamics", + "build-receipt.json" + )); +}); + +test("validates the source before cleanup and passes deeply frozen canonical initialization", async (t) => { + const project = await createLoadTestProject(t, { + configSource: "nested:\n values: [1, 2]", + source: providerFactorySource() + sourceExport(`(initialization) => { + const config = /** @type {any} */ (initialization.config); + if (!Object.isFrozen(initialization) + || !Object.isFrozen(config) + || !Object.isFrozen(config.nested) + || !Object.isFrozen(config.nested.values) + || initialization.seed !== "load-seed" + || initialization.sim_seconds_per_tick !== 0.5) { + throw new Error("initialization was not canonical and frozen"); + } + return { + id: "frozen-source", + live_acceptance: false, + onTick() {}, + participants: ["blue"], + provenance: "scripted", + version: "simfile.dynamics-run-action-source.v1" + }; +}`) + }); + const loaded = await loadDynamicsRunActionSource( + withGrants(project.simfile), + project.options + ); + assert.equal(loaded?.actionSource?.id, "frozen-source"); + await assertPathMissing(path.join(project.scratchRoot, "dynamics")); +}); + +test("rejects source participants when world grants are absent", async (t) => { + const project = await createLoadTestProject(t, { + source: providerFactorySource() + sourceExport(`() => ({ + id: "undeclared", + live_acceptance: false, + onTick() {}, + participants: ["blue"], + provenance: "scripted", + version: "simfile.dynamics-run-action-source.v1" +})`) + }); + await assert.rejects( + loadDynamicsRunActionSource(project.simfile, project.options), + /not declared in world\.grants/u + ); + await assertPathMissing(path.join(project.scratchRoot, "dynamics")); +}); diff --git a/src/dynamics/loadRunActionSource.ts b/src/dynamics/loadRunActionSource.ts new file mode 100644 index 0000000..b9a73f8 --- /dev/null +++ b/src/dynamics/loadRunActionSource.ts @@ -0,0 +1,124 @@ +import { types } from "node:util"; + +import type { Simfile } from "../schema/model.js"; +import { + parseWorldSurfaceDefinition, + type WorldSurfaceRegistry +} from "../world-surface/index.js"; +import type { LoadDynamicsSessionOptions } from "./loadCore.js"; +import { loadDynamicsCore } from "./loadCore.js"; +import { + parseDynamicsRunActionSourceDeclaration, + type DynamicsRunActionSourceDeclaration, + type DynamicsRunActionSourceFactory, + type DynamicsRunActionSourceInitialization +} from "./runActionSource.js"; +import type { DynamicsSession } from "./session.js"; + +export interface LoadedDynamicsRun { + readonly actionSource?: DynamicsRunActionSourceDeclaration; + readonly session: DynamicsSession; + readonly surfaceRegistry?: WorldSurfaceRegistry; +} + +interface LoadedDynamicsRunExtensions { + readonly actionSource?: DynamicsRunActionSourceDeclaration; + readonly surfaceRegistry?: WorldSurfaceRegistry; +} + +const isPromiseLike = (value: unknown): value is PromiseLike => + types.isPromise(value) + || ( + typeof value === "object" + && value !== null + && !types.isProxy(value) + && (() => { + let current: object | null = value; + while (current !== null) { + const descriptor = Object.getOwnPropertyDescriptor(current, "then"); + if (descriptor !== undefined) { + return "value" in descriptor + && typeof descriptor.value === "function"; + } + current = Object.getPrototypeOf(current); + } + return false; + })() + ); + +const loadActionSource = ( + loaded: Record, + initialization: DynamicsRunActionSourceInitialization, + simfile: Simfile +): DynamicsRunActionSourceDeclaration | undefined => { + const rawFactory = loaded.createDynamicsRunActionSource; + if (rawFactory === undefined) return undefined; + if (typeof rawFactory !== "function") { + throw new Error( + "dynamics module createDynamicsRunActionSource export must be a function" + ); + } + const rawSource = (rawFactory as DynamicsRunActionSourceFactory)( + initialization + ); + if (isPromiseLike(rawSource)) { + throw new Error("createDynamicsRunActionSource() must be synchronous"); + } + if (rawSource === undefined) return undefined; + return parseDynamicsRunActionSourceDeclaration( + rawSource, + new Set(Object.keys(simfile.world?.grants ?? {})) + ); +}; + +const loadSurfaceRegistry = ( + loaded: Record +): WorldSurfaceRegistry | undefined => { + const rawFactory = loaded.createWorldSurfaceDefinition; + if (rawFactory === undefined) return undefined; + if (typeof rawFactory !== "function") { + throw new Error( + "dynamics module createWorldSurfaceDefinition export must be a function" + ); + } + const rawSurface = rawFactory(); + if (isPromiseLike(rawSurface)) { + throw new Error("createWorldSurfaceDefinition() must be synchronous"); + } + return parseWorldSurfaceDefinition(rawSurface); +}; + +const loadRunExtensions = ( + loaded: Record, + initialization: DynamicsRunActionSourceInitialization, + simfile: Simfile +): LoadedDynamicsRunExtensions => { + const actionSource = loadActionSource(loaded, initialization, simfile); + const surfaceRegistry = loadSurfaceRegistry(loaded); + return Object.freeze({ + ...(actionSource === undefined ? {} : { actionSource }), + ...(surfaceRegistry === undefined ? {} : { surfaceRegistry }) + }); +}; + +export const loadDynamicsRunActionSource = async ( + simfile: Simfile, + options: LoadDynamicsSessionOptions +): Promise => { + const loaded = await loadDynamicsCore( + simfile, + options, + (artifact, initialization) => + loadRunExtensions(artifact, initialization, simfile) + ); + if (loaded === undefined) return undefined; + return { + session: loaded.session, + ...(loaded.extension.actionSource === undefined + ? {} + : { actionSource: loaded.extension.actionSource }), + ...(loaded.extension.surfaceRegistry === undefined + ? {} + : { surfaceRegistry: loaded.extension.surfaceRegistry }) + }; +}; diff --git a/src/dynamics/modulePath.test.ts b/src/dynamics/modulePath.test.ts new file mode 100644 index 0000000..9908549 --- /dev/null +++ b/src/dynamics/modulePath.test.ts @@ -0,0 +1,143 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import { resolveDynamicsModule } from "./modulePath.js"; + +interface TestProject { + directory: string; + simfilePath: string; +} + +const createProject = async (): Promise => { + const directory = await mkdtemp(path.join(os.tmpdir(), "simfile-module-path-")); + const simfilePath = path.join(directory, "Simfile"); + await writeFile(simfilePath, "clock: {}\n", "utf8"); + return { directory, simfilePath }; +}; + +const removeProject = async (project: TestProject): Promise => { + await rm(project.directory, { force: true, recursive: true }); +}; + +const writeEntry = async (project: TestProject, reference: string, source = "entry bytes"): Promise => { + const entryPath = path.join(project.directory, ...reference.slice(2).split("/")); + await mkdir(path.dirname(entryPath), { recursive: true }); + await writeFile(entryPath, source, "utf8"); + return entryPath; +}; + +describe("resolveDynamicsModule", () => { + it("accepts the approved .ts and .mjs authored forms", async () => { + const project = await createProject(); + try { + for (const [reference, source] of [ + ["./systems/provider.ts", "typescript entry"], + ["./systems/provider.mjs", "module entry"] + ]) { + const entryPath = await writeEntry(project, reference, source); + const resolved = await resolveDynamicsModule(project.simfilePath, reference); + assert.deepEqual(resolved, { + absolutePath: await realpath(entryPath), + module: reference, + moduleSha256: createHash("sha256").update(source).digest("hex"), + projectRoot: await realpath(project.directory) + }); + } + } finally { + await removeProject(project); + } + }); + + it("preserves portable segment compatibility", async () => { + const project = await createProject(); + try { + for (const reference of [ + "./.hidden/_provider-file.mjs", + "./Systems/Physics.ts", + "./-vendor/provider.mjs" + ]) { + await writeEntry(project, reference); + assert.equal((await resolveDynamicsModule(project.simfilePath, reference)).module, reference); + } + } finally { + await removeProject(project); + } + }); + + it("rejects hostile module references", async () => { + const project = await createProject(); + try { + for (const reference of [ + "systems/provider.mjs", "/systems/provider.mjs", "./systems/provider.d.ts", + "./systems/provider.tsx", "./systems/provider.js", "./systems/provider.mts", + "./systems/provider.cts", "./systems/provider.TS", "./systems/provider.MJS", + "./systems/provider.ts.bak", "https://example.test/provider.mjs", + "./systems/provider.mjs?x=1", "./systems/provider.mjs#part", "./systems/\0provider.mjs", + "./systems\\provider.mjs", "./systems/../provider.mjs", "./systems/./provider.mjs", + "./systems//provider.mjs", "./../provider.mjs", "././provider.mjs" + ]) { + await assert.rejects(resolveDynamicsModule(project.simfilePath, reference)); + } + } finally { + await removeProject(project); + } + }); + + it("rejects non-string references without invoking their endsWith hook", async () => { + const project = await createProject(); + let endsWithTouched = false; + const hostileReference = { + get endsWith(): never { + endsWithTouched = true; + throw new Error("hostile endsWith getter invoked"); + } + }; + try { + await assert.rejects( + resolveDynamicsModule(project.simfilePath, hostileReference as unknown as string), + /portable/u + ); + assert.equal(endsWithTouched, false); + } finally { + await removeProject(project); + } + }); + + it("fails closed for a symlinked Simfile and every checked entry component", async () => { + const project = await createProject(); + try { + const linkedSimfile = path.join(project.directory, "LinkedSimfile"); + await symlink(project.simfilePath, linkedSimfile); + await assert.rejects(resolveDynamicsModule(linkedSimfile, "./provider.mjs"), /non-symlink/u); + + const target = await writeEntry(project, "./target.mjs"); + await mkdir(path.join(project.directory, "systems")); + await symlink(target, path.join(project.directory, "systems", "linked.mjs")); + await assert.rejects(resolveDynamicsModule(project.simfilePath, "./systems/linked.mjs"), /symlinks/u); + + const realDirectory = path.join(project.directory, "real-systems"); + await mkdir(realDirectory); + await writeFile(path.join(realDirectory, "provider.mjs"), "entry", "utf8"); + await symlink(realDirectory, path.join(project.directory, "linked-systems")); + await assert.rejects(resolveDynamicsModule(project.simfilePath, "./linked-systems/provider.mjs"), /symlinks/u); + } finally { + await removeProject(project); + } + }); + + it("rejects missing paths and a directory leaf", async () => { + const project = await createProject(); + try { + await assert.rejects(resolveDynamicsModule(project.simfilePath, "./missing.mjs")); + await mkdir(path.join(project.directory, "directory.mjs")); + await assert.rejects(resolveDynamicsModule(project.simfilePath, "./directory.mjs"), /regular file/u); + await assert.rejects(resolveDynamicsModule(path.join(project.directory, "missing-simfile"), "./provider.mjs")); + } finally { + await removeProject(project); + } + }); +}); diff --git a/src/dynamics/modulePath.ts b/src/dynamics/modulePath.ts new file mode 100644 index 0000000..4f22624 --- /dev/null +++ b/src/dynamics/modulePath.ts @@ -0,0 +1,89 @@ +import { createHash } from "node:crypto"; +import { lstat, readFile, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { DYNAMICS_BUILD_CONTRACT } from "./buildInput.js"; +import { DYNAMICS_LIMITS } from "./limits.js"; + +export interface ResolvedDynamicsModule { + absolutePath: string; + module: string; + moduleSha256: string; + projectRoot: string; +} + +const isContained = (root: string, candidate: string): boolean => { + const relative = path.relative(root, candidate); + return relative === "" || (!relative.startsWith(`..${path.sep}`) && relative !== ".." && !path.isAbsolute(relative)); +}; + +const validateModuleReference = (moduleReference: string): string[] => { + if (typeof moduleReference !== "string") { + throw new Error("dynamics.module must be a portable ./ project-relative .ts or .mjs path"); + } + const hasAllowedExtension = DYNAMICS_BUILD_CONTRACT.allowedExtensions + .some((extension) => moduleReference.endsWith(extension)); + if ( + moduleReference.length > DYNAMICS_LIMITS.identifier_code_units + || !moduleReference.startsWith("./") + || moduleReference.includes("\\") + || moduleReference.includes("\0") + || moduleReference.includes("?") + || moduleReference.includes("#") + || !hasAllowedExtension + ) { + throw new Error("dynamics.module must be a portable ./ project-relative .ts or .mjs path"); + } + const segments = moduleReference.slice(2).split("/"); + const leaf = segments.at(-1); + if ( + leaf === undefined + || leaf.endsWith(".d.ts") + || segments.some((segment) => !/^[A-Za-z0-9._-]+$/u.test(segment) || segment === "." || segment === "..") + ) { + throw new Error("dynamics.module must contain only portable path segments"); + } + return segments; +}; + +/** + * Resolves only the declared entry point. The module remains trusted project + * code: this does not restrict its imports, I/O, environment, or network use. + */ +export const resolveDynamicsModule = async ( + simfilePath: string, + moduleReference: string +): Promise => { + const segments = validateModuleReference(moduleReference); + const absoluteSimfile = path.resolve(simfilePath); + const simfileStat = await lstat(absoluteSimfile); + if (simfileStat.isSymbolicLink() || !simfileStat.isFile()) { + throw new Error(`Simfile path must be a regular non-symlink file: ${simfilePath}`); + } + + const projectRoot = await realpath(path.dirname(absoluteSimfile)); + let current = projectRoot; + for (let index = 0; index < segments.length; index += 1) { + current = path.join(current, segments[index] as string); + const stat = await lstat(current); + if (stat.isSymbolicLink()) { + throw new Error(`dynamics.module must not contain symlinks: ${moduleReference}`); + } + const isLeaf = index === segments.length - 1; + if ((!isLeaf && !stat.isDirectory()) || (isLeaf && !stat.isFile())) { + throw new Error(`dynamics.module must resolve to a regular file: ${moduleReference}`); + } + } + + const absolutePath = await realpath(current); + if (!isContained(projectRoot, absolutePath)) { + throw new Error(`dynamics.module escapes the Simfile project: ${moduleReference}`); + } + const source = await readFile(absolutePath); + return { + absolutePath, + module: `./${segments.join("/")}`, + moduleSha256: createHash("sha256").update(source).digest("hex"), + projectRoot + }; +}; diff --git a/src/dynamics/publicContractFixture.test.ts b/src/dynamics/publicContractFixture.test.ts new file mode 100644 index 0000000..ba37a1f --- /dev/null +++ b/src/dynamics/publicContractFixture.test.ts @@ -0,0 +1,450 @@ +import assert from "node:assert/strict"; +import { access, cp, lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { execFile } from "node:child_process"; +import { fileURLToPath, pathToFileURL } from "node:url"; +import { promisify } from "node:util"; +import { describe, it } from "node:test"; +import ts from "typescript"; + +import { ensurePublicPackageBuild } from "../publicPackageBuild.test-helper.js"; +import { parseSimfileSource } from "../schema/parse.js"; +import { loadDynamicsSession, parseWorldSurfaceDefinition } from "./index.js"; +import * as dynamics from "./index.js"; + +const fixtureRoot = path.resolve( + fileURLToPath(new URL("../../fixtures/sims/public-dynamics-contract/", import.meta.url)) +); +const simfilePath = path.join(fixtureRoot, "Simfile"); +const contractPath = path.join(fixtureRoot, "systems", "contract.ts"); +const runtimeContractPath = path.join(fixtureRoot, "systems", "runtime-contract.ts"); +const packageRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url))); +const fixtureSystemsPath = path.join(fixtureRoot, "systems"); +const tscPath = path.join(packageRoot, "node_modules", "typescript", "bin", "tsc"); +const execFileAsync = promisify(execFile); + +type ContractModule = { + createDynamicsProvider: () => { id: string }; + createWorldSurfaceDefinition: () => unknown; +}; + +const loadContract = async (): Promise => + import(pathToFileURL(contractPath).href) as Promise; + +const publicTypes = [ + "DynamicsActionAttempt", + "DynamicsActionIngressEvidence", + "DynamicsActionQueueReceipt", + "DynamicsActionResolution", + "DynamicsActionResult", + "DynamicsBuildArtifactLifecycle", + "DynamicsCommitmentOutcomeDraft", + "DynamicsEvent", + "DynamicsEventDraft", + "DynamicsJsonObject", + "DynamicsJsonValue", + "DynamicsObservation", + "DynamicsObservationChannel", + "DynamicsObservationRequest", + "DynamicsProvenance", + "DynamicsProvider", + "DynamicsProviderObservation", + "DynamicsRunActionSourceInitialization", + "DynamicsRunActionSourceTick", + "DynamicsRunControllerAction", + "DynamicsSession", + "DynamicsSessionSnapshot", + "DynamicsSpatialFrame", + "DynamicsStepResult", + "PreparedDynamicsBuild", + "ReadonlyDynamicsJsonObject", + "WorldAffordanceLoweringInput", + "WorldMechanicsResult", + "WorldSenseProjectionInput", + "WorldSurfaceDefinition" +].sort(); + +const addedPublicTypes = [ + "DynamicsBuildArtifactLifecycle", + "PreparedDynamicsBuild" +].sort(); + +const addedPublicRuntimeValues = [ + "canonicalDynamicsJson", + "parseDynamicsActionAttempt", + "parseDynamicsProvenance", + "createDynamicsBuildReceipt", + "parseDynamicsSessionSnapshot", + "persistDynamicsBuild", + "prepareDynamicsBuild" +].sort(); + +const existingPublicRuntimeValues = [ + "DYNAMICS_OBSERVATION_VERSION", + "DYNAMICS_PROVIDER_API_VERSION", + "DYNAMICS_SNAPSHOT_VERSION", + "loadDynamicsSession" +].sort(); + +const publicDynamicsSpecifier = "simfile/dynamics"; +type FixtureSourceClass = "shipped system module" | "fixture test"; + +const publicImportNames = ( + source: string, + sourcePath: string, + sourceClass: FixtureSourceClass = "shipped system module" +): string[] => { + const parsed = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.ES2023, true); + const names: string[] = []; + const permittedExternalSpecifiers = new Set(["matter-js", "node:crypto", publicDynamicsSpecifier]); + const assertFixtureSpecifier = (specifier: string): void => { + const normalized = path.posix.normalize(specifier); + if (sourceClass === "shipped system module") { + assert.ok( + (specifier.startsWith("./") && !normalized.startsWith("../")) + || permittedExternalSpecifiers.has(specifier), + `${sourcePath}: ${sourceClass} may not import private Simfile paths (${specifier})` + ); + return; + } + const segments = specifier.split("/"); + const isRelative = specifier.startsWith("./") || specifier.startsWith("../"); + const relativeToFixtureRoot = path.relative( + fixtureRoot, + path.resolve(path.dirname(sourcePath), specifier) + ); + const staysInsideFixture = isRelative + && relativeToFixtureRoot !== ".." + && !relativeToFixtureRoot.startsWith(`..${path.sep}`) + && !path.isAbsolute(relativeToFixtureRoot); + const isPublicPackageImport = !segments.includes("src") + && ( + specifier.startsWith("node:") + || specifier === "matter-js" + || specifier === "simfile" + || specifier.startsWith("simfile/") + ); + assert.ok( + staysInsideFixture || isPublicPackageImport, + `${sourcePath}: ${sourceClass} may not import private Simfile paths (${specifier})` + ); + }; + const assertNoRuntimePublicImport = (specifier: string, form: string): void => { + assertFixtureSpecifier(specifier); + if (sourceClass === "shipped system module") { + assert.notEqual(specifier, publicDynamicsSpecifier, `${sourcePath}: ${sourceClass} must use import type declarations for ${publicDynamicsSpecifier}, not ${form}`); + } + }; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node)) { + assert.ok(ts.isStringLiteral(node.moduleSpecifier), `${sourcePath}: ${sourceClass} imports require a string specifier`); + const specifier = node.moduleSpecifier.text; + assertFixtureSpecifier(specifier); + if (sourceClass === "shipped system module" && specifier === publicDynamicsSpecifier) { + assert.ok(node.importClause?.isTypeOnly, `${sourcePath}: ${sourceClass} ${publicDynamicsSpecifier} import must be type-only`); + assert.ok( + node.importClause.namedBindings && ts.isNamedImports(node.importClause.namedBindings), + `${sourcePath}: ${sourceClass} ${publicDynamicsSpecifier} import must use named public types` + ); + for (const element of node.importClause.namedBindings.elements) { + assert.equal(element.propertyName, undefined, `${sourcePath}: ${sourceClass} public types may not be aliased`); + names.push(element.name.text); + } + } + } + if (ts.isExportDeclaration(node) && node.moduleSpecifier) { + assert.ok(ts.isStringLiteral(node.moduleSpecifier), `${sourcePath}: ${sourceClass} exports require a string specifier`); + assertNoRuntimePublicImport(node.moduleSpecifier.text, "export-from"); + } + if (ts.isImportEqualsDeclaration(node)) { + assert.ok(ts.isExternalModuleReference(node.moduleReference) && node.moduleReference.expression && ts.isStringLiteral(node.moduleReference.expression), `${sourcePath}: ${sourceClass} import-equals is not permitted`); + assertNoRuntimePublicImport(node.moduleReference.expression.text, "import-equals/require"); + } + if (ts.isImportTypeNode(node)) { + assert.ok( + ts.isLiteralTypeNode(node.argument) && ts.isStringLiteral(node.argument.literal), + `${sourcePath}: ${sourceClass} import types require a string specifier` + ); + const specifier = node.argument.literal.text; + assertFixtureSpecifier(specifier); + if (sourceClass === "shipped system module") { + assert.notEqual(specifier, publicDynamicsSpecifier, `${sourcePath}: ${sourceClass} public types must use import type declarations`); + } + } + if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) { + assert.ok( + node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0]), + `${sourcePath}: ${sourceClass} dynamic imports require exactly one string specifier` + ); + assertNoRuntimePublicImport(node.arguments[0].text, "dynamic import"); + } + if (ts.isCallExpression(node) && ts.isIdentifier(node.expression) && node.expression.text === "require") { + assert.ok( + node.arguments.length === 1 && ts.isStringLiteral(node.arguments[0]), + `${sourcePath}: ${sourceClass} require calls require exactly one string specifier` + ); + assertNoRuntimePublicImport(node.arguments[0].text, "require"); + } + ts.forEachChild(node, visit); + }; + visit(parsed); + return names.sort(); +}; + +const publicRuntimeImportNames = (source: string, sourcePath: string): string[] => { + const parsed = ts.createSourceFile(sourcePath, source, ts.ScriptTarget.ES2023, true); + const names: string[] = []; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) { + assert.equal(node.moduleSpecifier.text, publicDynamicsSpecifier, `${sourcePath}: runtime imports must use ${publicDynamicsSpecifier}`); + assert.equal(node.importClause?.isTypeOnly, false, `${sourcePath}: runtime imports must not be type-only`); + assert.ok(node.importClause?.namedBindings && ts.isNamedImports(node.importClause.namedBindings), `${sourcePath}: runtime imports must be named`); + for (const element of (node.importClause.namedBindings as ts.NamedImports).elements) { + assert.equal(element.propertyName, undefined, `${sourcePath}: runtime imports may not be aliased`); + names.push(element.name.text); + } + } + if (ts.isImportTypeNode(node) || ts.isImportEqualsDeclaration(node)) { + assert.fail(`${sourcePath}: runtime contract may not use private type or require import forms`); + } + if (ts.isCallExpression(node) && (node.expression.kind === ts.SyntaxKind.ImportKeyword || (ts.isIdentifier(node.expression) && node.expression.text === "require"))) { + assert.fail(`${sourcePath}: runtime contract may not use dynamic import or require`); + } + ts.forEachChild(node, visit); + }; + visit(parsed); + return names.sort(); +}; + +describe("public dynamics contract fixture", () => { + it("uses only the erased public type surface and a neutral provider", async () => { + const source = await readFile(contractPath, "utf8"); + const imported = source.match(/import type \{([\s\S]+?)\} from "simfile\/dynamics";/u); + assert.ok(imported); + assert.deepEqual(imported[1].split(",").map((name) => name.trim()).filter(Boolean).sort(), publicTypes); + assert.doesNotMatch(source, /football|matter-js|tiny-football/iu); + assert.doesNotMatch(source, /from "(?:\.\/|\.\.\/|.*\/src\/)/u); + }); + + it("permits only the seven audited runtime additions without private source paths", async () => { + const source = await readFile(runtimeContractPath, "utf8"); + assert.doesNotMatch(source, /football|matter-js|tiny-football|(?:\.\.\/|\/src\/)/iu); + assert.deepEqual(publicRuntimeImportNames(source, runtimeContractPath), addedPublicRuntimeValues); + }); + + it("keeps pre-existing runtime exports separate from the audited additions", () => { + for (const name of existingPublicRuntimeValues) { + assert.ok(name in dynamics, `${name} must remain public`); + } + }); + + it("adds only the two audited non-wire types to the dynamics barrel", async () => { + const source = await readFile(path.join(packageRoot, "src", "dynamics", "index.ts"), "utf8"); + assert.deepEqual( + [...source.matchAll(/type (DynamicsBuildArtifactLifecycle|PreparedDynamicsBuild)/gu)].map((match) => match[1]).sort(), + addedPublicTypes + ); + assert.doesNotMatch(source, /type (?:DynamicsBuildArtifactEvidence|PersistDynamicsBuildOptions|DynamicsBuildReceipt(?:Payload|RuntimeIdentity)?)/u); + }); + + it("rejects import forms that could bypass the public dynamics boundary", () => { + const privateImportType = "type Private = import(\"../src/dynamics/index.js\").DynamicsProvider;"; + const computedDynamicImport = "import(\"simfile/\" + \"dynamics\");"; + const multiArgumentDynamicImport = "import(\"simfile/dynamics\", { with: {} });"; + const computedRequire = "require(\"simfile/\" + \"dynamics\");"; + const multiArgumentRequire = "require(\"simfile/dynamics\", {});"; + const fixtureTestPath = path.join(fixtureSystemsPath, "synthetic-fixture.test.ts"); + + assert.throws( + () => publicImportNames(privateImportType, "private-import-type.ts"), + /may not import private Simfile paths/u + ); + for (const source of [ + 'import x from "../../src/dynamics/index.js";', + 'import x from "../../../outside.js";', + 'import x from "simfile/src/dynamics/index.js";', + 'type X = import("simfile/src/dynamics/index.js").DynamicsProvider;' + ]) { + assert.throws( + () => publicImportNames(source, fixtureTestPath, "fixture test"), + /synthetic-fixture\.test\.ts: fixture test may not import private Simfile paths/u + ); + } + for (const source of [ + 'import assert from "node:assert/strict";', + 'import { parseWorldSurfaceDefinition } from "simfile";', + 'import { loadDynamicsSession } from "simfile/dynamics";', + 'import { x } from "../platform/dynamics/footballSession.test-helper.js";', + 'import { x } from "./vendor/pinned-package/src/runtime.js";' + ]) { + assert.deepEqual(publicImportNames(source, fixtureTestPath, "fixture test"), []); + } + for (const source of [ + 'import assert from "node:assert/strict";', + 'import { parseWorldSurfaceDefinition } from "simfile";', + 'import { loadDynamicsSession } from "simfile/dynamics";' + ]) { + assert.throws( + () => publicImportNames(source, "synthetic-system.ts"), + /synthetic-system\.ts: shipped system module/u + ); + } + for (const [source, sourcePath, form] of [ + [computedDynamicImport, "computed-dynamic-import.ts", "dynamic imports"], + [multiArgumentDynamicImport, "multi-argument-dynamic-import.ts", "dynamic imports"], + [computedRequire, "computed-require.ts", "require calls"], + [multiArgumentRequire, "multi-argument-require.ts", "require calls"] + ] as const) { + assert.throws(() => publicImportNames(source, sourcePath), new RegExp(`${form} require exactly one string specifier`, "u")); + } + }); + + it("typechecks type-only and runtime external consumers with the expected emission modes", async () => { + const outputDirectory = await mkdtemp(path.join(packageRoot, ".tmp-public-contract-")); + try { + await ensurePublicPackageBuild(packageRoot); + await execFileAsync(process.execPath, [ + tscPath, + "--pretty", "false", + "--target", "ES2023", + "--module", "NodeNext", + "--moduleResolution", "NodeNext", + "--strict", + "--skipLibCheck", + "--types", "node", + "--rootDir", path.dirname(contractPath), + "--outDir", outputDirectory, + contractPath, + runtimeContractPath + ], { cwd: packageRoot }); + + const emitted = await readFile(path.join(outputDirectory, "contract.js"), "utf8"); + assert.doesNotMatch( + emitted, + /(?:from\s*["']simfile(?:\/dynamics)?["']|import\(\s*["']simfile(?:\/dynamics)?["']\s*\)|require\(\s*["']simfile(?:\/dynamics)?["']\s*\))/u + ); + const runtimeEmitted = await readFile(path.join(outputDirectory, "runtime-contract.js"), "utf8"); + assert.match(runtimeEmitted, /from\s*["']simfile\/dynamics["']/u); + } finally { + await rm(outputDirectory, { force: true, recursive: true }); + } + }); + + it("executes every audited runtime API through a physical development-package dynamics export", async () => { + const consumerRoot = await realpath(await mkdtemp(path.join(tmpdir(), "simfile-dynamics-consumer-"))); + try { + await ensurePublicPackageBuild(packageRoot); + const installedPackageRoot = path.join(consumerRoot, "node_modules", "simfile"); + await mkdir(installedPackageRoot, { recursive: true }); + await Promise.all([ + cp(path.join(packageRoot, "dist"), path.join(installedPackageRoot, "dist"), { dereference: true, recursive: true }), + cp(path.join(packageRoot, "package.json"), path.join(installedPackageRoot, "package.json"), { dereference: true }), + cp(path.join(packageRoot, "package-lock.json"), path.join(installedPackageRoot, "package-lock.json"), { dereference: true }) + ]); + assert.equal((await lstat(installedPackageRoot)).isSymbolicLink(), false, "development consumer package must not be a symlink"); + assert.notEqual(await realpath(installedPackageRoot), packageRoot, "development consumer package must not resolve to the workspace"); + const installedManifest = JSON.parse(await readFile(path.join(installedPackageRoot, "package.json"), "utf8")) as { + dependencies?: Record; + }; + assert.equal(installedManifest.dependencies?.typescript, "5.9.3", "development package must declare runtime TypeScript"); + assert.equal(installedManifest.dependencies?.esbuild, "0.28.1", "development package must declare runtime esbuild"); + const copyRuntimePackage = async (specifier: string, nodeModulesRoot: string): Promise => { + const target = path.join(nodeModulesRoot, specifier); + await mkdir(path.dirname(target), { recursive: true }); + await cp(path.join(packageRoot, "node_modules", specifier), target, { dereference: true, recursive: true }); + }; + await Promise.all([ + "typescript", + "esbuild", + "@esbuild", + "zod", + "yaml", + "@types/node", + "undici-types" + ].map((specifier) => copyRuntimePackage(specifier, path.join(installedPackageRoot, "node_modules")))); + await Promise.all([ + "typescript", + "@types/node", + "undici-types" + ].map((specifier) => copyRuntimePackage(specifier, path.join(consumerRoot, "node_modules")))); + await writeFile(path.join(consumerRoot, "package.json"), '{"name":"external-consumer","private":true,"type":"module","version":"1.0.0"}\n'); + const externalFixtureRoot = path.join(consumerRoot, "fixture"); + await mkdir(path.join(externalFixtureRoot, "systems"), { recursive: true }); + await writeFile(path.join(externalFixtureRoot, "Simfile"), await readFile(simfilePath)); + await writeFile(path.join(externalFixtureRoot, "systems", "provider.mjs"), [ + "export const createDynamicsProvider = () => ({", + ' api_version: "simfile.dynamics-provider.v1",', + ' id: "external-public-contract",', + ' version: "1.0.0",', + ' state_schema_version: "external-public-contract.v1",', + " initialize() {},", + " observe() { return { channels: [] }; },", + " restore() {},", + " snapshot() { return {}; },", + " /** @param {{ tick: number }} input */", + " step(input) { return { action_results: [], events: [], tick: input.tick }; }", + "});" + ].join("\n")); + const scratchRoot = path.join(consumerRoot, "scratch"); + const evidenceRoot = path.join(consumerRoot, "evidence"); + await mkdir(scratchRoot); + await mkdir(evidenceRoot); + + const externalRuntimeContract = path.join(consumerRoot, "runtime-contract.mts"); + await writeFile(externalRuntimeContract, await readFile(runtimeContractPath)); + const outputDirectory = path.join(consumerRoot, "out"); + await execFileAsync(process.execPath, [ + path.join(consumerRoot, "node_modules", "typescript", "bin", "tsc"), + "--pretty", "false", + "--target", "ES2023", + "--module", "NodeNext", + "--moduleResolution", "NodeNext", + "--strict", + "--skipLibCheck", + "--types", "node", + "--rootDir", consumerRoot, + "--outDir", outputDirectory, + externalRuntimeContract + ], { cwd: consumerRoot }); + + const consumerPath = path.join(consumerRoot, "consumer.mjs"); + await writeFile(consumerPath, [ + 'import assert from "node:assert/strict";', + 'import { access } from "node:fs/promises";', + 'import { exercisePublicDynamicsRuntime } from "./out/runtime-contract.mjs";', + `const paths = await exercisePublicDynamicsRuntime(${JSON.stringify({ + evidenceRoot, + moduleReference: "./systems/provider.mjs", + scratchRoot, + simfilePath: path.join(externalFixtureRoot, "Simfile") + })});`, + 'await assert.rejects(() => access(paths.artifactPath), { code: "ENOENT" });', + 'await assert.rejects(() => access(paths.receiptPath), { code: "ENOENT" });', + 'await access(paths.evidenceArtifactPath);', + 'await access(paths.evidenceReceiptPath);', + 'for (const specifier of ["simfile/dynamics/build", "simfile/dynamics/validation", "simfile/src/dynamics/index.js"]) {', + ' await assert.rejects(import(specifier), (error) => error?.code === "ERR_PACKAGE_PATH_NOT_EXPORTED");', + '}' + ].join("\n")); + await execFileAsync(process.execPath, [consumerPath], { cwd: consumerRoot }); + } finally { + await rm(consumerRoot, { force: true, recursive: true }); + } + }); + + it("loads the public provider and parses its public world surface without stepping", async () => { + const parsed = parseSimfileSource(await readFile(simfilePath, "utf8"), { path: simfilePath }).simfile; + const session = await loadDynamicsSession(parsed, { simfilePath }); + assert.ok(session); + assert.equal(session.provenance.provider_id, "public-contract-counter"); + assert.equal(session.nextTick, 0); + + const contract = await loadContract(); + const surface = parseWorldSurfaceDefinition(contract.createWorldSurfaceDefinition()); + assert.deepEqual(surface.entities.map(({ address }) => address), ["entity:counter"]); + assert.deepEqual(surface.senses.map(({ address }) => address), ["sense:counter"]); + assert.deepEqual(surface.affordances.map(({ address }) => address), ["affordance:increment"]); + assert.equal(contract.createDynamicsProvider().id, "public-contract-counter"); + }); +}); diff --git a/src/dynamics/publicDeclarationSurface.test.ts b/src/dynamics/publicDeclarationSurface.test.ts new file mode 100644 index 0000000..3bedc55 --- /dev/null +++ b/src/dynamics/publicDeclarationSurface.test.ts @@ -0,0 +1,60 @@ +import assert from "node:assert/strict"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { it } from "node:test"; +import ts from "typescript"; + +import { ensurePublicPackageBuild } from "../publicPackageBuild.test-helper.js"; + +const packageRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url))); +const distRoot = path.join(packageRoot, "dist"); +const entryPath = path.join(distRoot, "dynamics", "index.d.ts"); + +const normalizedRelativePath = (filePath: string): string => + path.relative(packageRoot, filePath).split(path.sep).join("/"); + +const declarationImportPath = (fromPath: string, specifier: string): string => { + const resolved = path.resolve(path.dirname(fromPath), specifier); + return /\.[cm]?js$/u.test(resolved) + ? resolved.replace(/\.([cm]?)js$/u, ".d.$1ts") + : resolved; +}; + +it("keeps the public dynamics declaration closure out of the package barrel and world server", async () => { + await ensurePublicPackageBuild(packageRoot); + const pending = [{ filePath: entryPath, chain: [normalizedRelativePath(entryPath)] }]; + const visited = new Set(); + + while (pending.length > 0) { + const current = pending.shift(); + assert.ok(current); + const relativePath = normalizedRelativePath(current.filePath); + assert.notEqual( + relativePath, + "dist/index.d.ts", + `dynamics declaration surface reached the package barrel: ${current.chain.join(" -> ")}` + ); + assert.equal( + relativePath.startsWith("dist/world-server/"), + false, + `dynamics declaration surface reached world-server: ${current.chain.join(" -> ")}` + ); + if (visited.has(current.filePath)) continue; + visited.add(current.filePath); + + const preprocessed = ts.preProcessFile(await readFile(current.filePath, "utf8"), true, true); + const specifiers = [...preprocessed.importedFiles, ...preprocessed.referencedFiles] + .map(({ fileName }) => fileName) + .filter((specifier) => specifier.startsWith(".")); + for (const specifier of specifiers) { + const filePath = declarationImportPath(current.filePath, specifier); + pending.push({ + filePath, + chain: [...current.chain, normalizedRelativePath(filePath)] + }); + } + } + + assert.ok(visited.size > 1, "dynamics declaration closure walk must traverse relative imports"); +}); diff --git a/src/dynamics/publicSurface.test.ts b/src/dynamics/publicSurface.test.ts new file mode 100644 index 0000000..2408e65 --- /dev/null +++ b/src/dynamics/publicSurface.test.ts @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { describe, it } from "node:test"; + +import { ensurePublicPackageBuild } from "../publicPackageBuild.test-helper.js"; +import * as dynamics from "./index.js"; + +const packageRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url))); +const tscPath = path.join(packageRoot, "node_modules", "typescript", "bin", "tsc"); +const execFileAsync = promisify(execFile); + +async function assertMissingRootExport(compile: Promise, diagnosticCode: "TS2305" | "TS2724", symbol: string): Promise { + let error: { stdout?: string; stderr?: string } | undefined; + try { + await compile; + } catch (caught) { + error = caught as { stdout?: string; stderr?: string }; + } + assert.ok(error, `expected simfile root import for ${symbol} to fail TypeScript compilation`); + assert.match(`${error.stdout ?? ""}${error.stderr ?? ""}`, new RegExp(`${diagnosticCode}: .*['\"]${symbol}['\"]`, "u")); +} + +describe("dynamics public surface", () => { + it("exposes the audited fixture boundary without a caller-controlled session constructor", () => { + assert.equal(typeof dynamics.loadDynamicsSession, "function"); + for (const name of [ + "canonicalDynamicsJson", + "parseDynamicsActionAttempt", + "parseDynamicsProvenance", + "prepareDynamicsBuild", + "persistDynamicsBuild", + "createDynamicsBuildReceipt", + "parseDynamicsSessionSnapshot" + ]) assert.equal(typeof dynamics[name as keyof typeof dynamics], "function", `${name} must be public`); + assert.equal("cloneDynamicsJson" in dynamics, false); + assert.equal("cloneDynamicsJsonObject" in dynamics, false); + assert.equal("createDynamicsSession" in dynamics, false); + assert.equal("DynamicsSession" in dynamics, false); + }); + + it("keeps B107 dynamics additions off the built root while preserving its existing contract", async () => { + const consumerRoot = await mkdtemp(path.join(tmpdir(), "simfile-root-dynamics-consumer-")); + try { + await ensurePublicPackageBuild(packageRoot); + await mkdir(path.join(consumerRoot, "node_modules"), { recursive: true }); + await symlink(packageRoot, path.join(consumerRoot, "node_modules", "simfile"), "dir"); + const acceptedSource = path.join(consumerRoot, "accepted.mts"); + await writeFile(acceptedSource, [ + 'import { DYNAMICS_OBSERVATION_VERSION, DYNAMICS_PROVIDER_API_VERSION, DYNAMICS_SNAPSHOT_VERSION, loadDynamicsSession } from "simfile";', + 'if (typeof loadDynamicsSession !== "function") throw new Error("missing root loadDynamicsSession");', + 'if (!DYNAMICS_OBSERVATION_VERSION || !DYNAMICS_PROVIDER_API_VERSION || !DYNAMICS_SNAPSHOT_VERSION) throw new Error("missing root dynamics versions");' + ].join("\n")); + await execFileAsync(process.execPath, [tscPath, "--pretty", "false", "--target", "ES2023", "--module", "NodeNext", "--moduleResolution", "NodeNext", "--strict", "--skipLibCheck", "--outDir", path.join(consumerRoot, "out"), acceptedSource], { cwd: consumerRoot }); + await execFileAsync(process.execPath, [path.join(consumerRoot, "out", "accepted.mjs")], { cwd: consumerRoot }); + + for (const [symbol, diagnosticCode] of [ + ["canonicalDynamicsJson", "TS2305"], + ["parseDynamicsActionAttempt", "TS2724"], + ["parseDynamicsProvenance", "TS2724"], + ["prepareDynamicsBuild", "TS2305"], + ["persistDynamicsBuild", "TS2305"], + ["createDynamicsBuildReceipt", "TS2724"], + ["parseDynamicsSessionSnapshot", "TS2724"] + ] as const) { + const forbiddenSource = path.join(consumerRoot, `forbidden-${symbol}.mts`); + await writeFile(forbiddenSource, `import { ${symbol} } from "simfile";\nvoid ${symbol};\n`); + await assertMissingRootExport( + execFileAsync(process.execPath, [tscPath, "--pretty", "false", "--target", "ES2023", "--module", "NodeNext", "--moduleResolution", "NodeNext", "--strict", "--skipLibCheck", "--noEmit", forbiddenSource], { cwd: consumerRoot }), + diagnosticCode, + symbol + ); + } + for (const symbol of ["DynamicsBuildArtifactLifecycle", "PreparedDynamicsBuild"]) { + const forbiddenSource = path.join(consumerRoot, `forbidden-${symbol}.mts`); + await writeFile(forbiddenSource, `import type { ${symbol} } from "simfile";\ndeclare const value: ${symbol};\nvoid value;\n`); + await assertMissingRootExport( + execFileAsync(process.execPath, [tscPath, "--pretty", "false", "--target", "ES2023", "--module", "NodeNext", "--moduleResolution", "NodeNext", "--strict", "--skipLibCheck", "--noEmit", forbiddenSource], { cwd: consumerRoot }), + "TS2305", + symbol + ); + } + } finally { + await rm(consumerRoot, { force: true, recursive: true }); + } + }); +}); diff --git a/src/dynamics/retainedCapacity.ts b/src/dynamics/retainedCapacity.ts new file mode 100644 index 0000000..905ffe0 --- /dev/null +++ b/src/dynamics/retainedCapacity.ts @@ -0,0 +1,28 @@ +const issuedCapacityErrors = new WeakSet(); + +type RetainedCapacityKind = "records" | "code_units" | "sequence"; +export type DynamicsRetainedActionCapacityError = Error & { readonly kind: RetainedCapacityKind }; + +export const dynamicsRetainedActionCapacityMessage = (kind: RetainedCapacityKind): string => + `dynamics retained action ${kind} capacity reached`; + +class IssuedDynamicsRetainedActionCapacityError extends Error { + readonly kind: RetainedCapacityKind; + + constructor(kind: RetainedCapacityKind) { + super(dynamicsRetainedActionCapacityMessage(kind)); + this.name = "DynamicsRetainedActionCapacityError"; + this.kind = kind; + issuedCapacityErrors.add(this); + } +} + +export const issueDynamicsRetainedActionCapacityError = ( + kind: RetainedCapacityKind, +): DynamicsRetainedActionCapacityError => new IssuedDynamicsRetainedActionCapacityError(kind); + +export const isDynamicsRetainedActionCapacityError = ( + value: unknown, +): value is DynamicsRetainedActionCapacityError => + value instanceof Error && issuedCapacityErrors.has(value) && + (value as DynamicsRetainedActionCapacityError).name === "DynamicsRetainedActionCapacityError"; diff --git a/src/dynamics/root.ts b/src/dynamics/root.ts new file mode 100644 index 0000000..bd864fb --- /dev/null +++ b/src/dynamics/root.ts @@ -0,0 +1,5 @@ +export * from "./load.js"; +export type { DynamicsSession } from "./session.js"; +export * from "./limits.js"; +export * from "./types.js"; +export * from "../world-surface/index.js"; diff --git a/src/dynamics/runActionSource.test.ts b/src/dynamics/runActionSource.test.ts new file mode 100644 index 0000000..49c0426 --- /dev/null +++ b/src/dynamics/runActionSource.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DYNAMICS_RUN_ACTION_SOURCE_VERSION, + parseDynamicsRunActionSourceDeclaration +} from "./runActionSource.js"; + +const valid = () => ({ + id: "scripted-source", + live_acceptance: false as const, + onTick() {}, + participants: ["blue", "red"], + provenance: "scripted" as const, + version: DYNAMICS_RUN_ACTION_SOURCE_VERSION +}); + +const declared = new Set(["blue", "red"]); + +test("parses and freezes the exact scripted non-live declaration", () => { + const raw = valid(); + const source = parseDynamicsRunActionSourceDeclaration(raw, declared); + assert.deepEqual( + { ...source, onTick: undefined }, + { ...raw, onTick: undefined } + ); + assert.equal(source.onTick, raw.onTick); + assert.equal(Object.isFrozen(source), true); + assert.equal(Object.isFrozen(source.participants), true); +}); + +test("requires exact declaration fields and ordinary data properties", () => { + assert.throws( + () => parseDynamicsRunActionSourceDeclaration( + { ...valid(), extra: true }, + declared + ), + /must contain exactly/u + ); + const accessor = valid() as Record; + Object.defineProperty(accessor, "id", { + enumerable: true, + get: () => "hostile" + }); + assert.throws( + () => parseDynamicsRunActionSourceDeclaration(accessor, declared), + /enumerable data value/u + ); + assert.throws( + () => parseDynamicsRunActionSourceDeclaration( + new Proxy(valid(), {}), + declared + ), + /ordinary object/u + ); + assert.throws( + () => parseDynamicsRunActionSourceDeclaration( + Object.assign(Object.create(null), valid()), + declared + ), + /ordinary object/u + ); +}); + +test("requires the exact version and closed provenance vocabulary", () => { + assert.throws( + () => parseDynamicsRunActionSourceDeclaration( + { ...valid(), version: "v2" }, + declared + ), + /version must be simfile\.dynamics-run-action-source\.v1/u + ); + assert.throws( + () => parseDynamicsRunActionSourceDeclaration( + { ...valid(), provenance: "live" }, + declared + ), + /provenance is unsupported/u + ); + const model = parseDynamicsRunActionSourceDeclaration( + { ...valid(), provenance: "model" }, + declared + ); + assert.equal(model.provenance, "model"); + assert.equal(model.live_acceptance, false); +}); + +test("requires sorted unique declared participant ids", () => { + for (const [participants, message] of [ + [[], /non-empty ordinary array/u], + [["red", "blue"], /code-point order/u], + [["blue", "blue"], /unique/u], + [["blue", "other"], /not declared in world\.grants/u], + [[" blue", "red"], /non-empty trimmed string/u] + ] as const) { + assert.throws( + () => parseDynamicsRunActionSourceDeclaration( + { ...valid(), participants }, + declared + ), + message + ); + } +}); + +test("rejects sparse, accessor, extended, subclassed, and proxied arrays", () => { + const sparse = ["blue", "red"]; + delete sparse[0]; + const accessor = ["blue", "red"]; + Object.defineProperty(accessor, "0", { + enumerable: true, + get: () => "blue" + }); + const extended = Object.assign(["blue", "red"], { extra: true }); + class ParticipantList extends Array {} + const subclassed = new ParticipantList("blue", "red"); + for (const participants of [ + sparse, + accessor, + extended, + subclassed, + new Proxy(["blue", "red"], {}) + ]) { + assert.throws( + () => parseDynamicsRunActionSourceDeclaration( + { ...valid(), participants }, + declared + ), + /ordinary array|dense data array/u + ); + } +}); + +test("requires an onTick function and bounded source identity", () => { + assert.throws( + () => parseDynamicsRunActionSourceDeclaration( + { ...valid(), onTick: 1 }, + declared + ), + /onTick must be a function/u + ); + assert.throws( + () => parseDynamicsRunActionSourceDeclaration( + { ...valid(), id: "x".repeat(257) }, + declared + ), + /identifier code-unit limit/u + ); +}); diff --git a/src/dynamics/runActionSource.ts b/src/dynamics/runActionSource.ts new file mode 100644 index 0000000..7c6079e --- /dev/null +++ b/src/dynamics/runActionSource.ts @@ -0,0 +1,199 @@ +import { types } from "node:util"; + +import type { WorldControllerAction } from "../world/controllerAuthority.js"; +import { DYNAMICS_LIMITS } from "./limits.js"; +import type { + DynamicsActionQueueReceipt, + ReadonlyDynamicsJsonObject +} from "./types.js"; +import type { WorldActIngressReceipt } from "../world/actTypes.js"; + +export const DYNAMICS_RUN_ACTION_SOURCE_VERSION = + "simfile.dynamics-run-action-source.v1" as const; + +export const DYNAMICS_RUN_ACTION_SOURCE_PROVENANCES = [ + "scripted", + "model" +] as const; +export type DynamicsRunActionSourceProvenance = + typeof DYNAMICS_RUN_ACTION_SOURCE_PROVENANCES[number]; + +export interface DynamicsRunActionSourceInitialization { + readonly config: ReadonlyDynamicsJsonObject; + readonly seed: string; + readonly sim_seconds_per_tick: number; +} + +export type DynamicsRunControllerAction = WorldControllerAction; +export interface DynamicsRunWorldActRequest { + readonly affordance: string; + readonly target: string; + readonly input: unknown; +} + +export interface DynamicsRunActionSourceTick { + readonly next_tick: number; + readonly sim_time: number; + observe(participant: string, request: unknown): unknown; + act?( + participant: string, + request: DynamicsRunWorldActRequest + ): WorldActIngressReceipt; + queueController( + action: DynamicsRunControllerAction + ): DynamicsActionQueueReceipt; +} + +export interface DynamicsRunActionSourceDeclaration { + readonly id: string; + readonly live_acceptance: boolean; + readonly participants: readonly string[]; + readonly provenance: DynamicsRunActionSourceProvenance; + readonly version: typeof DYNAMICS_RUN_ACTION_SOURCE_VERSION; + onTick(context: DynamicsRunActionSourceTick): void | PromiseLike; +} + +export type DynamicsRunActionSourceFactory = ( + initialization: DynamicsRunActionSourceInitialization +) => DynamicsRunActionSourceDeclaration | undefined; + +const DECLARATION_KEYS = [ + "id", + "live_acceptance", + "onTick", + "participants", + "provenance", + "version" +] as const; + +const codePointOrder = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +const ordinaryDataObject = ( + value: unknown, + expectedKeys: readonly string[], + label: string +): Record => { + if ( + value === null + || typeof value !== "object" + || Array.isArray(value) + || types.isProxy(value) + || Object.getPrototypeOf(value) !== Object.prototype + ) { + throw new Error(`${label} must be an ordinary object`); + } + const keys = Reflect.ownKeys(value); + if ( + keys.length !== expectedKeys.length + || keys.some((key) => typeof key !== "string" || !expectedKeys.includes(key)) + ) { + throw new Error(`${label} must contain exactly ${expectedKeys.join(", ")}`); + } + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) { + throw new Error(`${label}.${String(key)} must be an enumerable data value`); + } + } + return value as Record; +}; + +const identifier = (value: unknown, label: string): string => { + if ( + typeof value !== "string" + || value.length === 0 + || value !== value.trim() + ) { + throw new Error(`${label} must be a non-empty trimmed string`); + } + if (value.length > DYNAMICS_LIMITS.identifier_code_units) { + throw new Error(`${label} exceeds the dynamics identifier code-unit limit`); + } + return value; +}; + +const participantList = (value: unknown): readonly string[] => { + if ( + !Array.isArray(value) + || types.isProxy(value) + || Object.getPrototypeOf(value) !== Array.prototype + || value.length === 0 + ) { + throw new Error( + "dynamics run action source participants must be a non-empty ordinary array" + ); + } + const keys = Reflect.ownKeys(value); + if (keys.length !== value.length + 1 || !keys.includes("length")) { + throw new Error( + "dynamics run action source participants must be a dense data array" + ); + } + const participants: string[] = []; + for (let index = 0; index < value.length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !("value" in descriptor)) { + throw new Error( + "dynamics run action source participants must be a dense data array" + ); + } + participants.push(identifier( + descriptor.value, + `dynamics run action source participants[${index}]` + )); + } + if (new Set(participants).size !== participants.length) { + throw new Error("dynamics run action source participants must be unique"); + } + const sorted = [...participants].sort(codePointOrder); + if (participants.some((participant, index) => participant !== sorted[index])) { + throw new Error( + "dynamics run action source participants must be in code-point order" + ); + } + return Object.freeze(participants); +}; + +export const parseDynamicsRunActionSourceDeclaration = ( + value: unknown, + declaredParticipants: ReadonlySet +): DynamicsRunActionSourceDeclaration => { + const source = ordinaryDataObject( + value, + DECLARATION_KEYS, + "dynamics run action source" + ); + if (source.version !== DYNAMICS_RUN_ACTION_SOURCE_VERSION) { + throw new Error( + `dynamics run action source version must be ${DYNAMICS_RUN_ACTION_SOURCE_VERSION}` + ); + } + if (!DYNAMICS_RUN_ACTION_SOURCE_PROVENANCES.includes( + source.provenance as DynamicsRunActionSourceProvenance + )) { + throw new Error("dynamics run action source provenance is unsupported"); + } + if (source.live_acceptance !== false) { + throw new Error("dynamics run action source live_acceptance must be false"); + } + if (typeof source.onTick !== "function") { + throw new Error("dynamics run action source onTick must be a function"); + } + const participants = participantList(source.participants); + for (const participant of participants) { + if (!declaredParticipants.has(participant)) { + throw new Error( + `dynamics run action source participant ${participant} is not declared in world.grants` + ); + } + } + return Object.freeze({ + id: identifier(source.id, "dynamics run action source id"), + live_acceptance: false, + onTick: source.onTick as DynamicsRunActionSourceDeclaration["onTick"], + participants, + provenance: source.provenance as DynamicsRunActionSourceProvenance, + version: DYNAMICS_RUN_ACTION_SOURCE_VERSION + }); +}; diff --git a/src/dynamics/sameDynamicsSessionSnapshot.ts b/src/dynamics/sameDynamicsSessionSnapshot.ts new file mode 100644 index 0000000..a7376f7 --- /dev/null +++ b/src/dynamics/sameDynamicsSessionSnapshot.ts @@ -0,0 +1,9 @@ +import { isDeepStrictEqual } from "node:util"; + +import type { DynamicsSessionSnapshot } from "./types.js"; + +/** Compares snapshots already issued by a checked, resource-bounded session. */ +export const sameDynamicsSessionSnapshot = ( + left: DynamicsSessionSnapshot, + right: DynamicsSessionSnapshot, +): boolean => isDeepStrictEqual(left, right); diff --git a/src/dynamics/sequenceWatermark.ts b/src/dynamics/sequenceWatermark.ts new file mode 100644 index 0000000..625e48c --- /dev/null +++ b/src/dynamics/sequenceWatermark.ts @@ -0,0 +1,49 @@ +import { DYNAMICS_LIMITS } from "./limits.js"; +import { issueDynamicsRetainedActionCapacityError } from "./retainedCapacity.js"; +import type { DynamicsActionSequenceWatermark } from "./types.js"; + +export interface DynamicsActionSequenceIndex { + add(sequence: number): void; + addAll(sequences: readonly number[]): void; + has(sequence: number): boolean; + restore(value: DynamicsActionSequenceWatermark): void; + snapshot(): DynamicsActionSequenceWatermark; +} + +export const createDynamicsActionSequenceIndex = ( + initial: DynamicsActionSequenceWatermark = { floor: 1, above_floor: [] } +): DynamicsActionSequenceIndex => { + let floor = initial.floor; + let above = new Set(initial.above_floor); + const addAll = (sequences: readonly number[]): void => { + let nextFloor = floor; + const nextAbove = new Set(above); + for (const sequence of sequences) { + if (sequence < nextFloor || nextAbove.has(sequence)) continue; + if (sequence === nextFloor) { + nextFloor += 1; + while (nextAbove.delete(nextFloor)) nextFloor += 1; + } else { + if (nextAbove.size >= DYNAMICS_LIMITS.retained_action_records) { + throw issueDynamicsRetainedActionCapacityError("records"); + } + nextAbove.add(sequence); + } + } + floor = nextFloor; + above = nextAbove; + }; + return { + add: (sequence): void => addAll([sequence]), + addAll, + has: (sequence): boolean => sequence < floor || above.has(sequence), + restore: (value): void => { + floor = value.floor; + above = new Set(value.above_floor); + }, + snapshot: (): DynamicsActionSequenceWatermark => ({ + floor, + above_floor: [...above].sort((left, right) => left - right) + }) + }; +}; diff --git a/src/dynamics/session.test.ts b/src/dynamics/session.test.ts new file mode 100644 index 0000000..14774f9 --- /dev/null +++ b/src/dynamics/session.test.ts @@ -0,0 +1,421 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { loadDynamicsSession } from "./load.js"; +import { + dynamicsRetainedActionCapacityMessage, + issueDynamicsRetainedActionCapacityError, + isDynamicsRetainedActionCapacityError +} from "./retainedCapacity.js"; +import { isDynamicsRetryableStepFailure } from "./session.js"; +import { counterAction, counterObservationRequest, createDynamicsTestProject, removeDynamicsTestProject } from "./testSupport.test-helper.js"; + +describe("DynamicsSession", () => { + it("recognizes only issued permanent ingress-capacity errors", () => { + for (const kind of ["records", "code_units"] as const) { + const issued = issueDynamicsRetainedActionCapacityError(kind); + assert.equal(issued.kind, kind); + assert.equal(issued.message, dynamicsRetainedActionCapacityMessage(kind)); + assert.equal(isDynamicsRetainedActionCapacityError(issued), true); + assert.equal(isDynamicsRetainedActionCapacityError(new Error(issued.message)), false); + assert.equal(isDynamicsRetainedActionCapacityError({ name: issued.name, kind }), false); + class ErrorSubclassLookalike extends Error { + readonly kind = issued.kind; + + constructor() { + super(issued.message); + this.name = issued.name; + } + } + assert.equal(isDynamicsRetainedActionCapacityError(new ErrorSubclassLookalike()), false); + } + }); + + it("queues authenticated commands and applies them atomically at a fixed tick", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + assert.equal(session.observe(counterObservationRequest).channels[0]?.components.value, 2); + + assert.deepEqual(session.queueAction(counterAction()), { + act_id: "act-1", + apply_tick: 0, + queued: true, + sequence: 1 + }); + assert.deepEqual(session.queueAction(counterAction()), { + act_id: "act-1", + apply_tick: 0, + queued: true, + sequence: 1 + }); + assert.deepEqual(session.queueAction(counterAction({ input: { amount: 99 } })), { + act_id: "act-1", + apply_tick: 0, + code: "act_id_conflict", + queued: false + }); + assert.deepEqual(session.queueAction(counterAction({ + actor: "agent:blue", + principal_id: "moltnet:blue" + })), { + act_id: "act-1", + apply_tick: 0, + queued: true, + sequence: 2 + }); + const wrongTick = { + act_id: "future", + apply_tick: 0, + code: "wrong_tick", + queued: false + } as const; + assert.deepEqual(session.queueAction(counterAction({ act_id: "future", at_tick: 2 })), wrongTick); + assert.deepEqual(session.queueAction(counterAction({ act_id: "future", at_tick: 2 })), wrongTick); + + const ingressEvidence = session.readActionIngressEvidence(0); + assert.deepEqual( + ingressEvidence.map((entry) => entry.ordinal), + [1, 2, 3] + ); + assert.deepEqual( + ingressEvidence.map((entry) => entry.record.receipt.code ?? "queued"), + ["queued", "queued", "wrong_tick"] + ); + session.acknowledgeActionIngressEvidence(3); + assert.deepEqual(session.readActionIngressEvidence(0), []); + + const step = session.step(); + assert.deepEqual(step.action_results.map((result) => ({ + accepted: result.accepted, + act_id: result.act_id, + sequence: result.sequence + })), [ + { accepted: true, act_id: "act-1", sequence: 1 }, + { accepted: true, act_id: "act-1", sequence: 2 } + ]); + assert.deepEqual(step.events.map((event) => ({ + causes: event.cause_action_sequences, + provenance: event.provenance, + sequence: event.event_sequence, + tick: event.tick + })), [ + { causes: [1], provenance: "mechanical", sequence: 1, tick: 0 }, + { causes: [2], provenance: "mechanical", sequence: 2, tick: 0 } + ]); + const observation = session.observe(counterObservationRequest); + assert.equal(observation.version, "simfile.numeric-observation.v1"); + assert.equal(observation.tick, 1); + assert.equal(observation.channels[0]?.components.value, 8); + assert.equal(observation.channels[0]?.components.last_dt_seconds, 0.5); + assert.equal(observation.channels[0]?.components.sim_seconds_per_tick, 0.5); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("restores pending inputs and replays the same continuation exactly", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + session.queueAction(counterAction()); + const originalReceipt = session.queueAction(counterAction()); + session.step(); + const checkpoint = session.snapshot(); + + session.queueAction(counterAction({ act_id: "act-2", at_tick: 1, input: { amount: 4 } })); + const firstStep = session.step(); + const firstObservation = session.observe(counterObservationRequest); + const firstFinal = session.snapshot(); + + session.restore(checkpoint); + assert.deepEqual(session.queueAction(counterAction()), { + act_id: originalReceipt.act_id, + apply_tick: 1, + code: "wrong_tick", + queued: false + }); + session.restore(checkpoint); + session.queueAction(counterAction({ act_id: "act-2", at_tick: 1, input: { amount: 4 } })); + assert.deepEqual(session.step(), firstStep); + assert.deepEqual(session.observe(counterObservationRequest), firstObservation); + assert.deepEqual(session.snapshot(), firstFinal); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("filters observation channels by exact granted sense address", async () => { + const project = await createDynamicsTestProject(` +export const createDynamicsProvider = () => { + let state = { value: 1 }; + return { + api_version: "simfile.dynamics-provider.v1", + id: "leaky", + version: "1", + state_schema_version: "v1", + initialize() {}, + observe() { return { channels: [{ + components: { value: state.value }, + sense_address: "sense:secret", + subject_address: "object:secret" + }] }; }, + restore(value) { state = structuredClone(value); }, + snapshot() { return structuredClone(state); }, + step(input) { return { action_results: [], events: [], tick: input.tick }; } + }; +}; +`); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + await assert.rejects(async () => session.observe(counterObservationRequest), /ungranted sense address/u); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("does not let a provider widen frozen sense grants", async () => { + const project = await createDynamicsTestProject(` +export const createDynamicsProvider = () => { + let state = { value: 1 }; + return { + api_version: "simfile.dynamics-provider.v1", + id: "grant-mutator", + version: "1", + state_schema_version: "v1", + initialize() {}, + observe(request) { + try { request.sense_addresses.push("sense:secret"); } catch {} + return { channels: [{ + components: { value: state.value }, + sense_address: "sense:secret", + subject_address: "object:secret" + }] }; + }, + restore(value) { state = structuredClone(value); }, + snapshot() { return structuredClone(state); }, + step(input) { return { action_results: [], events: [], tick: input.tick }; } + }; +}; +`); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + assert.throws(() => session.observe(counterObservationRequest), /ungranted sense address/u); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("allows one granted sense to compose numeric channels for multiple subjects", async () => { + const project = await createDynamicsTestProject(` +export const createDynamicsProvider = () => { + let state = { value: 1 }; + return { + api_version: "simfile.dynamics-provider.v1", + id: "composed-sense", + version: "1", + state_schema_version: "v1", + initialize() {}, + observe() { return { channels: [ + { components: { x: 2 }, sense_address: "sense:counter", subject_address: "object:z" }, + { components: { x: 1 }, sense_address: "sense:counter", subject_address: "object:a" } + ] }; }, + restore(value) { state = structuredClone(value); }, + snapshot() { return structuredClone(state); }, + step(input) { return { action_results: [], events: [], tick: input.tick }; } + }; +}; +`); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + assert.deepEqual( + session.observe(counterObservationRequest).channels.map((channel) => channel.subject_address), + ["object:a", "object:z"] + ); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("rolls back observation mutation and malformed step output", async () => { + const project = await createDynamicsTestProject(` +export const createDynamicsProvider = () => { + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", + id: "malformed", + version: "1", + state_schema_version: "v1", + initialize() {}, + observe() { state.value += 1; return { channels: [] }; }, + restore(value) { state = structuredClone(value); }, + snapshot() { return structuredClone(state); }, + step(input) { state.value += 10; return { action_results: [], events: [], tick: input.tick }; } + }; +}; +`); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + const initial = session.snapshot(); + assert.throws(() => session.observe(counterObservationRequest), /must not mutate state/u); + assert.deepEqual(session.snapshot(), initial); + + session.queueAction(counterAction()); + const queued = session.snapshot(); + assert.throws(() => session.step(), /resolve every queued action/u); + assert.deepEqual(session.snapshot(), queued); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("classifies only an issued provider-step throw after successful rollback", async () => { + const sentinelKey = "__simfile_retryable_step_identity_test__"; + const sentinel = Object.freeze({ provider: "secret", state: { restores: 0 } }); + const project = await createDynamicsTestProject(` +export const createDynamicsProvider = () => { + let throwStep = true; + let state = { value: 0 }; + return { api_version: "simfile.dynamics-provider.v1", id: "retry-marker", version: "1", state_schema_version: "v1", + initialize() {}, observe() { return { channels: [] }; }, restore(value) { Reflect.get(globalThis, "__simfile_retryable_step_identity_test__").state.restores += 1; state = structuredClone(value); }, snapshot() { return structuredClone(state); }, + step(input) { if (throwStep) { throwStep = false; throw Reflect.get(globalThis, "__simfile_retryable_step_identity_test__"); } state.value += 1; return { action_results: [], events: [], tick: input.tick }; } + }; +};`); + try { + Reflect.set(globalThis, sentinelKey, sentinel); + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); assert.ok(session); + const before = session.snapshot(); + let issued: unknown; + try { session.step(); } catch (error) { issued = error; } + assert.equal(isDynamicsRetryableStepFailure(issued), true); + assert.strictEqual((issued as Error).cause, sentinel); + assert.equal(sentinel.state.restores, 1); + assert.equal(isDynamicsRetryableStepFailure(new Error((issued as Error).message)), false); + assert.equal(isDynamicsRetryableStepFailure({ name: (issued as Error).name, message: (issued as Error).message }), false); + assert.equal(isDynamicsRetryableStepFailure(new Proxy(issued as object, {})), false); + assert.equal(isDynamicsRetryableStepFailure("checked step failed"), false); + assert.deepEqual(session.snapshot(), before); + assert.equal(session.step().tick, 0); + } finally { Reflect.deleteProperty(globalThis, sentinelKey); await removeDynamicsTestProject(project); } + }); + + it("retains primitive provider throws as retryable causes", async () => { + const project = await createDynamicsTestProject(` +export const createDynamicsProvider = () => { + let throwStep = true; + return { api_version: "simfile.dynamics-provider.v1", id: "primitive-retry", version: "1", state_schema_version: "v1", + initialize() {}, observe() { return { channels: [] }; }, restore() {}, snapshot() { return {}; }, + step(input) { if (throwStep) { throwStep = false; throw "provider primitive"; } return { action_results: [], events: [], tick: input.tick }; } + }; +};`); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); assert.ok(session); + let issued: unknown; + try { session.step(); } catch (error) { issued = error; } + assert.equal((issued as Error).cause, "provider primitive"); + assert.equal(isDynamicsRetryableStepFailure(issued), true); + assert.equal(session.step().tick, 0); + } finally { await removeDynamicsTestProject(project); } + }); + + it("does not classify checked output or rollback failure", async () => { + const malformed = await createDynamicsTestProject(` +export const createDynamicsProvider = () => ({ api_version: "simfile.dynamics-provider.v1", id: "bad-output", version: "1", state_schema_version: "v1", initialize() {}, observe() { return { channels: [] }; }, restore() {}, snapshot() { return {}; }, step() { return { action_results: [{ accepted: true, sequence: 1 }], events: [], tick: 0 }; } });`); + try { const session = await loadDynamicsSession(malformed.simfile, { simfilePath: malformed.simfilePath }); assert.ok(session); let error: unknown; try { session.step(); } catch (caught) { error = caught; } assert.equal(isDynamicsRetryableStepFailure(error), false); } finally { await removeDynamicsTestProject(malformed); } + const broken = await createDynamicsTestProject(` +export const createDynamicsProvider = () => ({ api_version: "simfile.dynamics-provider.v1", id: "bad-rollback", version: "1", state_schema_version: "v1", initialize() {}, observe() { return { channels: [] }; }, restore() { throw new Error("restore failed"); }, snapshot() { return {}; }, step() { throw new Error("step failed"); } });`); + try { const session = await loadDynamicsSession(broken.simfile, { simfilePath: broken.simfilePath }); assert.ok(session); let error: unknown; try { session.step(); } catch (caught) { error = caught; } assert.equal(isDynamicsRetryableStepFailure(error), false); assert.throws(() => session.snapshot(), /permanently closed/u); } finally { await removeDynamicsTestProject(broken); } + }); + + it("passes immutable command copies while retaining host-stamped action identity", async () => { + const project = await createDynamicsTestProject(` +export const createDynamicsProvider = () => { + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", + id: "command-boundary", + version: "1", + state_schema_version: "v1", + initialize() {}, + observe() { return { channels: [] }; }, + restore(value) { state = structuredClone(value); }, + snapshot() { return structuredClone(state); }, + step(input) { + const command = input.actions[0]; + const frozen = Object.isFrozen(input) && Object.isFrozen(input.actions) + && Object.isFrozen(command) && Object.isFrozen(command.input); + return { + action_results: [{ accepted: true, sequence: command.sequence }], + events: [{ + cause_action_sequences: [command.sequence], + kind: "boundary.checked", + payload: { command_keys: Object.keys(command).sort(), frozen }, + source: command.actor, + target: command.target + }], + tick: input.tick + }; + } + }; +}; +`); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + session.queueAction(counterAction()); + const result = session.step(); + assert.equal(result.action_results[0]?.actor, "agent:red"); + assert.equal(result.action_results[0]?.principal_id, "moltnet:red"); + assert.equal(result.events[0]?.payload.frozen, true); + assert.deepEqual(result.events[0]?.payload.command_keys, ["action", "actor", "input", "sequence", "target"]); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("rejects restore under different provenance without changing state", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + const before = session.snapshot(); + const tampered = structuredClone(before); + tampered.provenance.config_sha256 = "0".repeat(64); + assert.throws(() => session.restore(tampered), /identity does not match/u); + assert.deepEqual(session.snapshot(), before); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("rejects internally inconsistent host snapshots before touching provider state", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + session.queueAction(counterAction()); + const before = session.snapshot(); + + const badSequence = structuredClone(before); + badSequence.next_action_sequence = 8; + assert.throws(() => session.restore(badSequence), /contiguous/u); + + const badTick = structuredClone(before); + if (badTick.pending_actions[0]) badTick.pending_actions[0].at_tick = 1; + assert.throws(() => session.restore(badTick), /next_tick/u); + + const badReceipt = structuredClone(before); + if (badReceipt.action_ingress[0]?.receipt) badReceipt.action_ingress[0].receipt.sequence = 9; + assert.throws(() => session.restore(badReceipt), /contiguous|ingress receipt/u); + assert.deepEqual(session.snapshot(), before); + } finally { + await removeDynamicsTestProject(project); + } + }); +}); diff --git a/src/dynamics/session.ts b/src/dynamics/session.ts new file mode 100644 index 0000000..86f95a8 --- /dev/null +++ b/src/dynamics/session.ts @@ -0,0 +1,396 @@ +import { cloneDynamicsJson, cloneDynamicsJsonObject } from "./canonicalJson.js"; +import { cloneDynamicsActionIdempotencyRecord, createDynamicsActionIdempotencyRecord, + createDynamicsActionIngressEvidenceBuffer, dynamicsActionIdempotencyRecordCodeUnits, + dynamicsActionKey, sameDynamicsActionAttempt } from "./actionRetention.js"; +import { type DynamicsBuildReceipt } from "./buildReceipt.js"; +import { DYNAMICS_ACTION_RETENTION_LIMITS, DYNAMICS_LIMITS } from "./limits.js"; +import { issueDynamicsRetainedActionCapacityError } from "./retainedCapacity.js"; +import { createDynamicsActionSequenceIndex } from "./sequenceWatermark.js"; +import type { CreateDynamicsSessionOptions, DynamicsSession } from "./sessionContract.js"; +import { issueDynamicsRetryableStepFailure, isDynamicsRetryableStepFailure, + readCheckedDynamicsSession, registerCheckedDynamicsSession } from "./sessionIssuance.js"; +import { describeRollbackFailure, requireSynchronous, restoreProviderExactly, + sameDynamicsJson as sameJson } from "./sessionProviderBoundary.js"; +import { cloneAttempt, cloneQueuedAction, cloneReceipt, freezeJson, providerCommand } from "./sessionValues.js"; +import { compareDynamicsActionIdempotencyRecords, parseDynamicsSessionSnapshot } from "./snapshotValidation.js"; +import { parseDynamicsSpatialFrame } from "./spatialValidation.js"; +import { + DYNAMICS_SNAPSHOT_VERSION, type DynamicsActionAttempt, type DynamicsActionIdempotencyRecord, + type DynamicsActionIngressEvidence, type DynamicsActionQueueReceipt, type DynamicsCommand, + type DynamicsCommitmentOutcome, type DynamicsEvent, type DynamicsInitializeContext, + type DynamicsJsonObject, type DynamicsJsonValue, + type DynamicsObservation, type DynamicsProvider, type DynamicsProvenance, type DynamicsQueuedAction, + type DynamicsSessionSnapshot, type DynamicsSpatialFrame, type DynamicsStepResult, + type ReadonlyDynamicsJsonObject +} from "./types.js"; +import { parseDynamicsActionAttempt, parseDynamicsIntegration, parseDynamicsObservation, + parseDynamicsObservationRequest, parseDynamicsProvenance, parseDynamicsSeed, + parseDynamicsStepResult } from "./validation.js"; + +export type { DynamicsSession } from "./sessionContract.js"; +export { isDynamicsRetryableStepFailure, readCheckedDynamicsSession } from "./sessionIssuance.js"; + +class CheckedDynamicsSession implements DynamicsSession { + readonly #buildReceipt: DynamicsBuildReceipt; + readonly #provider: DynamicsProvider; + readonly #integration: DynamicsJsonObject; + readonly #provenance: DynamicsProvenance; + readonly #seed: string; + readonly #simSecondsPerTick: number; + #acceptedActionSequences = createDynamicsActionSequenceIndex(); + #actionIngress = new Map(); + #actionIngressFloor = 1; + #ingressEvidence = createDynamicsActionIngressEvidenceBuffer(); + #closed?: AggregateError; + #nextActionSequence = 1; + #nextEventSequence = 1; + #nextTick = 0; + #pendingActions: DynamicsQueuedAction[] = []; + #retainedActionCodeUnits = 0; + #resolvedActionSequences = createDynamicsActionSequenceIndex(); + + constructor(provider: DynamicsProvider, options: CreateDynamicsSessionOptions) { + const seed = parseDynamicsSeed(options.seed); + if (!Number.isFinite(options.simSecondsPerTick) || options.simSecondsPerTick <= 0) { + throw new Error("dynamics simSecondsPerTick must be a positive finite number"); + } + this.#buildReceipt = options.buildReceipt; + this.#provider = provider; + this.#integration = freezeJson(parseDynamicsIntegration(provider.integration)); + this.#provenance = parseDynamicsProvenance(options.provenance); + this.#seed = seed; + this.#simSecondsPerTick = options.simSecondsPerTick; + const provenance = this.provenance; + Object.freeze(provenance.provider_dependencies); + const context: DynamicsInitializeContext = Object.freeze({ + config: freezeJson(cloneDynamicsJsonObject(options.config, "dynamics config")), + provenance: Object.freeze(provenance), + seed: this.#seed, + sim_seconds_per_tick: this.#simSecondsPerTick + }); + requireSynchronous(this.#provider.initialize(context), "initialize"); + cloneDynamicsJson(requireSynchronous(this.#provider.snapshot(), "snapshot"), "dynamics provider snapshot"); + } + + #assertOpen(): void { + if (this.#closed) throw new Error("dynamics session is permanently closed after rollback failure", { cause: this.#closed }); + } + + #rollback(snapshot: DynamicsJsonValue, cause: unknown, retryableStepThrow = false): never { + try { + restoreProviderExactly(this.#provider, snapshot); + } catch (restoreError) { + this.#closed = new AggregateError( + [cause, restoreError], + `dynamics operation and rollback both failed\noperation: ${describeRollbackFailure(cause)}\nrollback: ${describeRollbackFailure(restoreError)}` + ); + throw this.#closed; + } + throw retryableStepThrow ? issueDynamicsRetryableStepFailure(cause) : cause; + } + + #simTime(): number { + const value = this.#nextTick * this.#simSecondsPerTick; + if (!Number.isFinite(value)) throw new Error("dynamics sim_time must remain finite"); + return value; + } + + get buildReceipt(): DynamicsBuildReceipt { + this.#assertOpen(); + return this.#buildReceipt; + } + + get nextTick(): number { + this.#assertOpen(); + return this.#nextTick; + } + + get integration(): ReadonlyDynamicsJsonObject { + this.#assertOpen(); + return freezeJson(cloneDynamicsJsonObject(this.#integration, "dynamics integration")); + } + + get provenance(): DynamicsProvenance { + this.#assertOpen(); + return parseDynamicsProvenance(this.#provenance); + } + + #retainAction(key: string, record: DynamicsActionIdempotencyRecord, size: number): void { + const evictable = [...this.#actionIngress.values()] + .filter((candidate) => candidate.retained_at_tick < this.#nextTick) + .sort(compareDynamicsActionIdempotencyRecords); + let evictableIndex = 0; + while ( + (this.#actionIngress.size >= DYNAMICS_ACTION_RETENTION_LIMITS.records + || this.#retainedActionCodeUnits + size > DYNAMICS_ACTION_RETENTION_LIMITS.code_units) + && evictableIndex < evictable.length + ) { + const oldest = evictable[evictableIndex]; + evictableIndex += 1; + if (!oldest) break; + this.#actionIngress.delete(dynamicsActionKey(oldest)); + this.#retainedActionCodeUnits -= dynamicsActionIdempotencyRecordCodeUnits(oldest); + } + if (this.#actionIngress.size >= DYNAMICS_ACTION_RETENTION_LIMITS.records) { + throw issueDynamicsRetainedActionCapacityError("records"); + } + if (this.#retainedActionCodeUnits + size > DYNAMICS_ACTION_RETENTION_LIMITS.code_units) { + throw issueDynamicsRetainedActionCapacityError("code_units"); + } + this.#actionIngress.set(key, record); + this.#retainedActionCodeUnits += size; + } + + readActionIngressEvidence(afterOrdinal: number): readonly DynamicsActionIngressEvidence[] { + this.#assertOpen(); + return this.#ingressEvidence.read(afterOrdinal); + } + + acknowledgeActionIngressEvidence(throughOrdinal: number): void { + this.#assertOpen(); + this.#ingressEvidence.acknowledge(throughOrdinal); + } + + queueAction(value: unknown): DynamicsActionQueueReceipt { + this.#assertOpen(); + const attempt = parseDynamicsActionAttempt(value); + const key = dynamicsActionKey(attempt); + const existing = this.#actionIngress.get(key); + if (existing) { + const receipt: DynamicsActionQueueReceipt = sameDynamicsActionAttempt(existing, attempt) + ? cloneReceipt(existing.receipt) + : { act_id: attempt.act_id, apply_tick: existing.receipt.apply_tick, code: "act_id_conflict", queued: false }; + /* Replays and conflicting reuse are pure reads. Recording either here + * would mutate the snapshot ordinal and create a second ledger cause; + * a rejected reuse is intentionally visible only through its receipt. */ + return cloneReceipt(receipt); + } + if (attempt.at_tick !== this.#nextTick) { + const receipt: DynamicsActionQueueReceipt = { + act_id: attempt.act_id, apply_tick: this.#nextTick, code: "wrong_tick", queued: false + }; + const record = createDynamicsActionIdempotencyRecord(attempt, receipt); + const size = dynamicsActionIdempotencyRecordCodeUnits(record); + this.#ingressEvidence.assertAvailable(); + this.#retainAction(key, record, size); + this.#ingressEvidence.emit(attempt, receipt); + return cloneReceipt(receipt); + } + /* + * Per-tick queue pressure is an ordinary, retryable ingress failure and must + * stay one: it is not a retention-capacity fault, and an attempt that fails + * here leaves no idempotency record and no ingress evidence behind, so the + * identical request can be retried on a later tick. + */ + if (this.#pendingActions.length >= DYNAMICS_LIMITS.actions_per_tick) { + throw new Error("dynamics pending action limit reached for this tick"); + } + if (!Number.isSafeInteger(this.#nextActionSequence) || this.#nextActionSequence >= Number.MAX_SAFE_INTEGER) { + throw issueDynamicsRetainedActionCapacityError("sequence"); + } + const action = { ...attempt, sequence: this.#nextActionSequence }; + const receipt: DynamicsActionQueueReceipt = { + act_id: attempt.act_id, apply_tick: this.#nextTick, queued: true, sequence: action.sequence + }; + const record = createDynamicsActionIdempotencyRecord(attempt, receipt); + const retainedSize = dynamicsActionIdempotencyRecordCodeUnits(record); + this.#ingressEvidence.assertAvailable(); + this.#nextActionSequence += 1; + this.#pendingActions.push(action); + this.#retainAction(key, record, retainedSize); + this.#ingressEvidence.emit(attempt, receipt); + return cloneReceipt(receipt); + } + + observe(value: unknown): DynamicsObservation { + this.#assertOpen(); + const request = parseDynamicsObservationRequest(value); + const before = cloneDynamicsJson(requireSynchronous(this.#provider.snapshot(), "snapshot"), "dynamics provider snapshot"); + try { + const raw = requireSynchronous(this.#provider.observe(Object.freeze({ + sense_addresses: Object.freeze([...request.sense_addresses]), + sim_time: this.#simTime(), + tick: this.#nextTick + })), "observe"); + const observation = parseDynamicsObservation(raw, request, this.#nextTick); + const after = cloneDynamicsJson(requireSynchronous(this.#provider.snapshot(), "snapshot"), "dynamics provider snapshot"); + if (!sameJson(before, after)) throw new Error("dynamics provider observe() must not mutate state"); + return observation; + } catch (error) { + return this.#rollback(before, error); + } + } + + /** + * Reads the provider's optional scene projection under the same + * no-mutation guarantee `observe()` gets: a provider must not be able to + * advance or perturb the simulation by being watched. + */ + spatial(): DynamicsSpatialFrame | undefined { + this.#assertOpen(); + if (this.#provider.spatial === undefined) return undefined; + const before = cloneDynamicsJson(requireSynchronous(this.#provider.snapshot(), "snapshot"), "dynamics provider snapshot"); + try { + const raw = requireSynchronous(this.#provider.spatial(), "spatial"); + const frame = parseDynamicsSpatialFrame(raw); + const after = cloneDynamicsJson(requireSynchronous(this.#provider.snapshot(), "snapshot"), "dynamics provider snapshot"); + if (!sameJson(before, after)) throw new Error("dynamics provider spatial() must not mutate state"); + return frame; + } catch (error) { + return this.#rollback(before, error); + } + } + + step(): DynamicsStepResult { + this.#assertOpen(); + if (this.#nextTick >= Number.MAX_SAFE_INTEGER) throw new Error("dynamics tick counter exhausted"); + if (this.#nextEventSequence >= Number.MAX_SAFE_INTEGER) throw new Error("dynamics event sequence counter exhausted"); + const simTime = this.#simTime(); + const before = cloneDynamicsJson(requireSynchronous(this.#provider.snapshot(), "snapshot"), "dynamics provider snapshot"); + const actions = this.#pendingActions.map(cloneQueuedAction); + const commands = actions.map(providerCommand); + try { + let raw: unknown; + try { raw = this.#provider.step(Object.freeze({ actions: Object.freeze(commands), dt_seconds: this.#simSecondsPerTick, sim_time: simTime, tick: this.#nextTick })); } + catch (error) { return this.#rollback(before, error, true); } + const checkedRaw = requireSynchronous(raw, "step"); + const parsed = parseDynamicsStepResult(checkedRaw, this.#nextTick, commands, this.#nextActionSequence); + if (this.#nextEventSequence + parsed.events.length > Number.MAX_SAFE_INTEGER) { + throw new Error("dynamics event sequence counter exhausted"); + } + const acceptedThisTick = new Set( + parsed.action_results.filter((result) => result.accepted).map((result) => result.sequence) + ); + for (const event of parsed.events) for (const sequence of event.cause_action_sequences) { + if (!acceptedThisTick.has(sequence) && !this.#acceptedActionSequences.has(sequence)) { + throw new Error(`dynamics event references rejected action sequence ${sequence}`); + } + } + for (const outcome of parsed.commitment_outcomes ?? []) { + const sequence = outcome.declaration_action_sequence; + if (!acceptedThisTick.has(sequence) && !this.#acceptedActionSequences.has(sequence)) { + throw new Error(`dynamics commitment outcome references rejected action sequence ${sequence}`); + } + } + cloneDynamicsJson(requireSynchronous(this.#provider.snapshot(), "snapshot"), "dynamics provider snapshot"); + const actionResults = parsed.action_results.map((resolution, index) => { + const action = actions[index]; + if (!action) throw new Error("dynamics result had no matching action"); + return { + ...resolution, act_id: action.act_id, action: action.action, actor: action.actor, + apply_tick: this.#nextTick, origin: action.origin, principal_id: action.principal_id, target: action.target + }; + }); + const events: DynamicsEvent[] = parsed.events.map((event, index) => ({ + ...event, event_sequence: this.#nextEventSequence + index, provenance: "mechanical", tick: this.#nextTick + })); + const commitmentOutcomes: DynamicsCommitmentOutcome[] | undefined = + parsed.commitment_outcomes?.map((outcome) => ({ + ...outcome, + provenance: "mechanical", + tick: this.#nextTick, + })); + this.#acceptedActionSequences.addAll([...acceptedThisTick]); + this.#resolvedActionSequences.addAll(parsed.action_results.map((result) => result.sequence)); + this.#nextEventSequence += events.length; + this.#pendingActions = []; + const tick = this.#nextTick; + this.#nextTick += 1; + this.#actionIngress = new Map([...this.#actionIngress.entries()].map(([key, record]) => { + if (!record.receipt.queued) return [key, record] as const; + return [key, { + ...record, + receipt: { act_id: record.act_id, apply_tick: this.#nextTick, code: "wrong_tick", queued: false } + }] as const; + })); + this.#retainedActionCodeUnits = [...this.#actionIngress.values()] + .reduce((total, record) => total + dynamicsActionIdempotencyRecordCodeUnits(record), 0); + this.#actionIngressFloor = this.#nextActionSequence; + /* + * The tick boundary is the retention horizon for every live ingress + * structure, evidence included. A run drains and acknowledges evidence + * before each step, so this is a no-op there; for a host that never reads + * evidence it is what stops an undrained buffer from accumulating across + * ticks and turning a per-tick bound into a session-lifetime action cap. + */ + this.#ingressEvidence.acknowledge(this.#ingressEvidence.ordinal); + return { + action_results: actionResults, + ...(commitmentOutcomes === undefined ? {} : { + commitment_outcomes: commitmentOutcomes, + }), + events, + tick, + }; + } catch (error) { + if (isDynamicsRetryableStepFailure(error)) throw error; + return this.#rollback(before, error); + } + } + + snapshot(): DynamicsSessionSnapshot { + this.#assertOpen(); + return { + accepted_action_sequences: this.#acceptedActionSequences.snapshot(), + action_ingress: [...this.#actionIngress.values()] + .sort(compareDynamicsActionIdempotencyRecords) + .map(cloneDynamicsActionIdempotencyRecord), + action_ingress_floor: this.#actionIngressFloor, + action_ingress_ordinal: this.#ingressEvidence.ordinal, + next_action_sequence: this.#nextActionSequence, + next_event_sequence: this.#nextEventSequence, + next_tick: this.#nextTick, + pending_actions: this.#pendingActions.map(cloneQueuedAction), + provider_state: cloneDynamicsJson(requireSynchronous(this.#provider.snapshot(), "snapshot"), "dynamics provider snapshot"), + provenance: this.provenance, + resolved_action_sequences: this.#resolvedActionSequences.snapshot(), + seed: this.#seed, + sim_seconds_per_tick: this.#simSecondsPerTick, + version: DYNAMICS_SNAPSHOT_VERSION + }; + } + + restore(value: unknown): void { + this.#assertOpen(); + const snapshot = parseDynamicsSessionSnapshot(value); + if ( + !sameJson(snapshot.provenance, this.#provenance) + || snapshot.seed !== this.#seed + || snapshot.sim_seconds_per_tick !== this.#simSecondsPerTick + ) throw new Error("dynamics snapshot initialization identity does not match this session"); + const current = this.snapshot(); + try { + restoreProviderExactly(this.#provider, snapshot.provider_state); + this.#acceptedActionSequences.restore(snapshot.accepted_action_sequences); + this.#actionIngress = new Map(snapshot.action_ingress.map((record) => [ + dynamicsActionKey(record), cloneDynamicsActionIdempotencyRecord(record) + ])); + this.#actionIngressFloor = snapshot.action_ingress_floor; + this.#ingressEvidence.restore(snapshot.action_ingress_ordinal); + this.#nextActionSequence = snapshot.next_action_sequence; + this.#nextEventSequence = snapshot.next_event_sequence; + this.#nextTick = snapshot.next_tick; + this.#pendingActions = snapshot.pending_actions.map(cloneQueuedAction); + this.#retainedActionCodeUnits = snapshot.action_ingress.reduce( + (total, record) => total + dynamicsActionIdempotencyRecordCodeUnits(record), + 0 + ); + this.#resolvedActionSequences.restore(snapshot.resolved_action_sequences); + } catch (error) { + return this.#rollback(current.provider_state, error); + } + } +} + +Object.freeze(CheckedDynamicsSession.prototype); + +/** Construction is exposed only to the sealed world-service artifact entrypoint. */ +export const createDynamicsSession = ( + provider: DynamicsProvider, + options: CreateDynamicsSessionOptions +): DynamicsSession => { + const session = new CheckedDynamicsSession(provider, options); + Object.freeze(session); + registerCheckedDynamicsSession(session); + return session; +}; diff --git a/src/dynamics/sessionCommitmentOutcomes.test.ts b/src/dynamics/sessionCommitmentOutcomes.test.ts new file mode 100644 index 0000000..10aa083 --- /dev/null +++ b/src/dynamics/sessionCommitmentOutcomes.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { loadDynamicsSession } from "./load.js"; +import { + counterAction, + createDynamicsTestProject, + removeDynamicsTestProject, +} from "./testSupport.test-helper.js"; + +const providerSource = (unknownSequence = false): string => ` +export const createDynamicsProvider = () => { + let state = { declared: 0, last_tick: -1 }; + return { + api_version: "simfile.dynamics-provider.v1", + id: "commitment-probe", + version: "1", + state_schema_version: "v1", + initialize() {}, + observe() { return { channels: [] }; }, + restore(value) { state = structuredClone(value); }, + snapshot() { return structuredClone(state); }, + step(input) { + const action_results = input.actions.map(({ sequence }) => { + state.declared = sequence; + return { accepted: true, sequence }; + }); + const commitment_outcomes = input.tick === 1 ? [{ + commitment_id: "commitment:alpha:1", + declaration_action_sequence: ${unknownSequence ? "999" : "state.declared"}, + outcome: "fulfilled", + participant: "object:participant.alpha" + }] : []; + state.last_tick = input.tick; + return { action_results, commitment_outcomes, events: [], tick: input.tick }; + } + }; +}; +`; + +test("stamps a prior accepted declaration outcome at its terminal tick", async () => { + const project = await createDynamicsTestProject(providerSource()); + try { + const session = await loadDynamicsSession(project.simfile, { + simfilePath: project.simfilePath, + }); + assert.ok(session); + session.queueAction(counterAction()); + assert.deepEqual(session.step().commitment_outcomes, []); + assert.deepEqual(session.step().commitment_outcomes, [{ + commitment_id: "commitment:alpha:1", + declaration_action_sequence: 1, + outcome: "fulfilled", + participant: "object:participant.alpha", + provenance: "mechanical", + tick: 1, + }]); + } finally { + await removeDynamicsTestProject(project); + } +}); + +test("rejects an outcome without an accepted declaration sequence", async () => { + const project = await createDynamicsTestProject(providerSource(true)); + try { + const session = await loadDynamicsSession(project.simfile, { + simfilePath: project.simfilePath, + }); + assert.ok(session); + session.queueAction(counterAction()); + session.step(); + assert.throws( + () => session.step(), + /commitment outcome references rejected action sequence 999/u, + ); + } finally { + await removeDynamicsTestProject(project); + } +}); diff --git a/src/dynamics/sessionContract.ts b/src/dynamics/sessionContract.ts new file mode 100644 index 0000000..aa45eaf --- /dev/null +++ b/src/dynamics/sessionContract.ts @@ -0,0 +1,40 @@ +import type { DynamicsBuildReceipt } from "./buildReceipt.js"; +import type { + DynamicsActionIngressEvidence, + DynamicsActionQueueReceipt, + DynamicsObservation, + DynamicsProvenance, + DynamicsSessionSnapshot, + DynamicsSpatialFrame, + DynamicsStepResult, + ReadonlyDynamicsJsonObject, +} from "./types.js"; + +export interface CreateDynamicsSessionOptions { + buildReceipt: DynamicsBuildReceipt; + config: Record; + provenance: DynamicsProvenance; + seed: string; + simSecondsPerTick: number; +} + +/** + * Host-owned facade returned only by loadDynamicsSession(). Callers can queue + * authenticated envelopes, observe exact granted senses, step, and checkpoint; + * they cannot inject a provider or forge provenance through construction. + */ +export interface DynamicsSession { + readonly buildReceipt: DynamicsBuildReceipt; + readonly integration: ReadonlyDynamicsJsonObject; + readonly nextTick: number; + readonly provenance: DynamicsProvenance; + observe(value: unknown): DynamicsObservation; + queueAction(value: unknown): DynamicsActionQueueReceipt; + readActionIngressEvidence(afterOrdinal: number): readonly DynamicsActionIngressEvidence[]; + acknowledgeActionIngressEvidence(throughOrdinal: number): void; + restore(value: unknown): void; + snapshot(): DynamicsSessionSnapshot; + /** `undefined` when the provider declares no spatial projection. */ + spatial(): DynamicsSpatialFrame | undefined; + step(): DynamicsStepResult; +} diff --git a/src/dynamics/sessionHardening.test.ts b/src/dynamics/sessionHardening.test.ts new file mode 100644 index 0000000..0d79f0a --- /dev/null +++ b/src/dynamics/sessionHardening.test.ts @@ -0,0 +1,425 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { canonicalDynamicsJson } from "./canonicalJson.js"; +import { DYNAMICS_ACTION_RETENTION_LIMITS, DYNAMICS_LIMITS } from "./limits.js"; +import { loadDynamicsSession } from "./load.js"; +import { + dynamicsRetainedActionCapacityMessage, + isDynamicsRetainedActionCapacityError +} from "./retainedCapacity.js"; +import { readCheckedDynamicsSession } from "./session.js"; +import { + counterAction, + counterObservationRequest, + createDynamicsTestProject, + removeDynamicsTestProject +} from "./testSupport.test-helper.js"; + +describe("DynamicsSession hardening", () => { + it("issues frozen module-attested sessions rather than structural lookalikes", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + assert.equal(readCheckedDynamicsSession(session), session); + assert.equal(readCheckedDynamicsSession({ ...session }), undefined); + assert.equal(readCheckedDynamicsSession(new Proxy(session, {})), undefined); + assert.equal(Object.isFrozen(session), true); + assert.throws(() => { (session as unknown as { step: unknown }).step = undefined; }, TypeError); + assert.throws(() => Object.setPrototypeOf(session, {}), TypeError); + } finally { + await removeDynamicsTestProject(project); + } + }); + it("permanently closes when provider rollback cannot restore state", async () => { + const project = await createDynamicsTestProject(` +export const createDynamicsProvider = () => { + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", + id: "broken-rollback", + version: "1", + state_schema_version: "v1", + initialize() {}, + observe() { return { channels: [] }; }, + restore() { throw new Error("restore is broken"); }, + snapshot() { return structuredClone(state); }, + step(input) { state.value += 1; return { action_results: [], events: [], tick: input.tick }; } + }; +}; +`); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + const checkpoint = session.snapshot(); + session.queueAction(counterAction()); + assert.throws(() => session.step(), /operation and rollback both failed/u); + for (const operation of [ + () => session.integration, + () => session.nextTick, + () => session.provenance, + () => session.queueAction(counterAction({ act_id: "later" })), + () => session.observe(counterObservationRequest), + () => session.step(), + () => session.snapshot(), + () => session.restore(checkpoint) + ]) assert.throws(operation, /permanently closed/u); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("surfaces both operation and rollback errors", async () => { + const project = await createDynamicsTestProject(` +export const createDynamicsProvider = () => ({ + api_version: "simfile.dynamics-provider.v1", + id: "double-failure", + version: "1", + state_schema_version: "v1", + initialize() {}, + observe() { return { channels: [] }; }, + restore() { throw new Error("distinctive restore failure"); }, + snapshot() { return { value: 0 }; }, + step() { throw new Error("distinctive operation failure"); } +}); +`); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + assert.throws( + () => session.step(), + (error: unknown) => { + assert.ok(error instanceof AggregateError); + assert.equal(error.errors.length, 2); + assert.match(error.message, /distinctive operation failure/u); + assert.match(error.message, /distinctive restore failure/u); + return true; + } + ); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("uses safe counters and refuses another tick before exhaustion", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + const checkpoint = session.snapshot(); + for (const field of ["next_tick", "next_event_sequence", "next_action_sequence"] as const) { + const unsafe = structuredClone(checkpoint); + unsafe[field] = 2 ** 53; + assert.throws(() => session.restore(unsafe), /safe integer/u); + } + assert.throws( + () => session.queueAction(counterAction({ act_id: "unsafe", at_tick: 2 ** 53 })), + /safe integer/u + ); + + const exhausted = structuredClone(checkpoint); + exhausted.next_tick = Number.MAX_SAFE_INTEGER; + session.restore(exhausted); + assert.throws(() => session.step(), /tick counter exhausted/u); + assert.equal(session.nextTick, Number.MAX_SAFE_INTEGER); + + const hugeClock = { + ...project.simfile, + clock: { ...project.simfile.clock, sim_per_tick: `${`1${"0".repeat(307)}`}ms` } + }; + const hugeTime = await loadDynamicsSession(hugeClock, { simfilePath: project.simfilePath }); + assert.ok(hugeTime); + const late = hugeTime.snapshot(); + late.next_tick = 100_000; + hugeTime.restore(late); + assert.throws(() => hugeTime.step(), /sim_time must remain finite/u); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("enforces identifier and provider-message code-unit limits", async () => { + const project = await createDynamicsTestProject(` +export const createDynamicsProvider = () => { + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", id: "text-limits", version: "1", state_schema_version: "v1", + initialize() {}, observe() { return { channels: [] }; }, restore(value) { state = structuredClone(value); }, + snapshot() { return structuredClone(state); }, + step(input) { return { action_results: input.actions.map((action) => ({ + accepted: true, message: "x".repeat(${DYNAMICS_LIMITS.message_code_units + 1}), sequence: action.sequence + })), events: [], tick: input.tick }; } + }; +}; +`); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + assert.throws( + () => session.queueAction(counterAction({ act_id: "x".repeat(DYNAMICS_LIMITS.identifier_code_units + 1) })), + /256 code-unit limit/u + ); + session.queueAction(counterAction({ act_id: "x".repeat(DYNAMICS_LIMITS.identifier_code_units) })); + assert.throws(() => session.step(), /4096 code-unit limit/u); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("enforces pending, retained-history, and sense-grant fuses", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + for (let index = 0; index < DYNAMICS_LIMITS.actions_per_tick; index += 1) { + session.queueAction(counterAction({ act_id: `act-${index}` })); + session.acknowledgeActionIngressEvidence(index + 1); + } + assert.throws( + () => session.queueAction(counterAction({ act_id: "overflow" })), + /pending action limit/u + ); + assert.throws(() => session.observe({ + observer: "agent:red", + principal_id: "moltnet:red", + sense_addresses: Array.from( + { length: DYNAMICS_LIMITS.sense_grants + 1 }, + (_, index) => `sense:s${index}` + ) + }), /sense grant limit/u); + + const oversized = session.snapshot() as unknown as { action_ingress: unknown[] }; + oversized.action_ingress = new Array(DYNAMICS_ACTION_RETENTION_LIMITS.records + 1); + assert.throws(() => session.restore(oversized), /retained ingress limit/u); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("bounds multiplicative action input and retained ingress code units", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + const multiplicative = Object.fromEntries(Array.from( + { length: Math.floor(DYNAMICS_LIMITS.json_code_units / 1_000) + 1 }, + (_, index) => [`value_${index}`, "x".repeat(1_000)] + )); + assert.throws( + () => session.queueAction(counterAction({ input: multiplicative })), + /cumulative.*code-unit limit/u + ); + assert.equal(session.snapshot().action_ingress.length, 0); + + const frontier = session.snapshot(); + const sequenceFloor = Number.MAX_SAFE_INTEGER - DYNAMICS_LIMITS.actions_per_tick; + frontier.accepted_action_sequences.floor = sequenceFloor; + frontier.action_ingress_floor = sequenceFloor; + frontier.next_action_sequence = sequenceFloor; + frontier.next_tick = Number.MAX_SAFE_INTEGER - 1; + frontier.resolved_action_sequences.floor = sequenceFloor; + session.restore(frontier); + const escaped = Array.from({ length: 32 }, (_, code) => String.fromCharCode(code)) + .filter((value) => JSON.stringify(value).length === 8); + assert.ok(escaped.length * escaped.length >= DYNAMICS_LIMITS.actions_per_tick); + let evidenceOrdinal = 0; + for (let index = 0; index < DYNAMICS_LIMITS.actions_per_tick; index += 1) { + const suffix = escaped[Math.floor(index / escaped.length)] + escaped[index % escaped.length]; + const identity = "\0".repeat(DYNAMICS_LIMITS.identifier_code_units - 2) + suffix; + session.queueAction(counterAction({ + act_id: identity, + at_tick: Number.MAX_SAFE_INTEGER - 1, + principal_id: identity + })); + const evidence = session.readActionIngressEvidence(evidenceOrdinal); + assert.equal(evidence.length, 1); + evidenceOrdinal = evidence[0]?.ordinal ?? evidenceOrdinal; + session.acknowledgeActionIngressEvidence(evidenceOrdinal); + } + const checkpoint = session.snapshot(); + const retainedCodeUnits = checkpoint.action_ingress.reduce( + (total, record) => total + canonicalDynamicsJson(record).length, + 0 + ); + assert.equal(checkpoint.action_ingress.length, DYNAMICS_ACTION_RETENTION_LIMITS.records); + assert.ok(checkpoint.action_ingress.every((record) => + canonicalDynamicsJson(record).length === DYNAMICS_ACTION_RETENTION_LIMITS.record_code_units)); + assert.equal(retainedCodeUnits, DYNAMICS_ACTION_RETENTION_LIMITS.code_units); + assert.equal(checkpoint.pending_actions.length, DYNAMICS_LIMITS.actions_per_tick); + + const rejected = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(rejected); + for (let index = 0; index < DYNAMICS_ACTION_RETENTION_LIMITS.records; index += 1) { + rejected.queueAction(counterAction({ act_id: `rejected-${index}`, at_tick: 1 })); + rejected.acknowledgeActionIngressEvidence(index + 1); + } + assert.throws( + () => rejected.queueAction(counterAction({ act_id: "retained-overflow", at_tick: 1 })), + (error) => isDynamicsRetainedActionCapacityError(error) + && error.kind === "records" + && error.message === dynamicsRetainedActionCapacityMessage("records") + ); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("bounds multiplicative snapshot, event, and observation values", async () => { + const multiplicative = Object.fromEntries(Array.from( + { length: Math.floor(DYNAMICS_LIMITS.json_code_units / 1_000) + 1 }, + (_, index) => [`value_${index}`, "x".repeat(1_000)] + )); + const snapshotProject = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(snapshotProject.simfile, { + simfilePath: snapshotProject.simfilePath + }); + assert.ok(session); + const snapshot = session.snapshot(); + snapshot.provider_state = multiplicative; + assert.throws(() => session.restore(snapshot), /cumulative.*code-unit limit/u); + } finally { + await removeDynamicsTestProject(snapshotProject); + } + + for (const [source, operation] of [[` +export const createDynamicsProvider = () => { + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", id: "large-event", version: "1", state_schema_version: "v1", + initialize() {}, observe() { return { channels: [] }; }, restore(value) { state = structuredClone(value); }, + snapshot() { return structuredClone(state); }, + step(input) { return { action_results: [], events: [{ + cause_action_sequences: [], kind: "large.event", + payload: Object.fromEntries(Array.from({ length: ${Math.floor(DYNAMICS_LIMITS.json_code_units / 1_000) + 1} }, (_, index) => ["v" + index, "x".repeat(1000)])), + source: "system:test", target: "object:test" + }], tick: input.tick }; } + }; +}; +`, "step"], [` +export const createDynamicsProvider = () => { + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", id: "large-observation", version: "1", state_schema_version: "v1", + initialize() {}, restore(value) { state = structuredClone(value); }, snapshot() { return structuredClone(state); }, + observe() { return { channels: Array.from({ length: ${DYNAMICS_LIMITS.observation_channels} }, (_, index) => ({ + components: { value: index }, sense_address: "sense:counter", + subject_address: "object:o" + index + "x".repeat(240 - String(index).length) + })) }; }, + step(input) { return { action_results: [], events: [], tick: input.tick }; } + }; +}; +`, "observe"]] as const) { + const project = await createDynamicsTestProject(source); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + assert.throws( + () => operation === "step" ? session.step() : session.observe(counterObservationRequest), + /cumulative.*code-unit limit/u + ); + } finally { + await removeDynamicsTestProject(project); + } + } + }); + + it("enforces provider event and cause fuses transactionally", async () => { + for (const [source, expected] of [ + [` +export const createDynamicsProvider = () => { + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", id: "event-fuse", version: "1", state_schema_version: "v1", + initialize() {}, observe() { return { channels: [] }; }, restore(value) { state = structuredClone(value); }, + snapshot() { return structuredClone(state); }, + step(input) { state.value += 1; return { + action_results: [], + events: Array.from({ length: ${DYNAMICS_LIMITS.events_per_tick + 1} }, () => ({ + cause_action_sequences: [], kind: "fuse.event", payload: {}, source: "system:test", target: "object:test" + })), tick: input.tick + }; } + }; +}; +`, /event limit/u], + [` +export const createDynamicsProvider = () => { + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", id: "cause-fuse", version: "1", state_schema_version: "v1", + initialize() {}, observe() { return { channels: [] }; }, restore(value) { state = structuredClone(value); }, + snapshot() { return structuredClone(state); }, + step(input) { state.value += 1; return { + action_results: [{ accepted: true, sequence: input.actions[0].sequence }], + events: [{ cause_action_sequences: Array.from({ length: ${DYNAMICS_LIMITS.causes_per_event + 1} }, () => 1), + kind: "fuse.cause", payload: {}, source: "system:test", target: "object:test" }], tick: input.tick + }; } + }; +}; +`, /cause limit/u] + ] as const) { + const project = await createDynamicsTestProject(source); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + if (source.includes("cause-fuse")) session.queueAction(counterAction()); + const before = session.snapshot(); + assert.throws(() => session.step(), expected); + assert.deepEqual(session.snapshot(), before); + } finally { + await removeDynamicsTestProject(project); + } + } + }); + + it("rejects resolved and rejected receipts with impossible temporal positions", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + session.queueAction(counterAction()); + session.step(); + const resolved = session.snapshot(); + const futureResolved = structuredClone(resolved); + futureResolved.resolved_action_sequences.floor += 1; + assert.throws(() => session.restore(futureResolved), /resolved sequence watermark/u); + + const fresh = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(fresh); + fresh.queueAction(counterAction({ act_id: "future", at_tick: 2 })); + const rejected = fresh.snapshot(); + const rejectedRecord = rejected.action_ingress[0]; + if (rejectedRecord) rejectedRecord.receipt.apply_tick = 1; + assert.throws(() => fresh.restore(rejected), /belong to next_tick/u); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("orders ingress by sequence and rejects pending/resolved chronology inversion", async () => { + const project = await createDynamicsTestProject(); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + session.queueAction(counterAction({ act_id: "z-first" })); + session.step(); + session.queueAction(counterAction({ act_id: "a-second", at_tick: 1 })); + const snapshot = session.snapshot(); + assert.deepEqual( + snapshot.action_ingress.filter((record) => record.receipt.queued) + .map((record) => [record.receipt.sequence, record.at_tick]), + [[2, 1]] + ); + + const inverted = structuredClone(snapshot); + inverted.action_ingress_floor = 1; + assert.throws( + () => session.restore(inverted), + /contiguous from the retained floor/u + ); + } finally { + await removeDynamicsTestProject(project); + } + }); +}); diff --git a/src/dynamics/sessionIssuance.ts b/src/dynamics/sessionIssuance.ts new file mode 100644 index 0000000..9ad047b --- /dev/null +++ b/src/dynamics/sessionIssuance.ts @@ -0,0 +1,22 @@ +import type { DynamicsSession } from "./sessionContract.js"; + +const issuedDynamicsSessions = new WeakSet(); +const retryableStepFailures = new WeakSet(); + +export const issueDynamicsRetryableStepFailure = (cause: unknown): Error => { + const failure = new Error("checked step failed", { cause }); + retryableStepFailures.add(failure); + return failure; +}; + +export const isDynamicsRetryableStepFailure = (value: unknown): boolean => + value !== null && typeof value === "object" && retryableStepFailures.has(value); + +export const readCheckedDynamicsSession = (value: unknown): DynamicsSession | undefined => + value !== null && typeof value === "object" && issuedDynamicsSessions.has(value) + ? value as DynamicsSession + : undefined; + +export const registerCheckedDynamicsSession = (session: DynamicsSession): void => { + issuedDynamicsSessions.add(session); +}; diff --git a/src/dynamics/sessionObservationHardening.test.ts b/src/dynamics/sessionObservationHardening.test.ts new file mode 100644 index 0000000..53bd1b0 --- /dev/null +++ b/src/dynamics/sessionObservationHardening.test.ts @@ -0,0 +1,50 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DYNAMICS_LIMITS } from "./limits.js"; +import { loadDynamicsSession } from "./load.js"; +import { + counterObservationRequest, + createDynamicsTestProject, + removeDynamicsTestProject +} from "./testSupport.test-helper.js"; + +test("enforces observation channel and component fuses", async () => { + for (const [source, expected] of [[` +export const createDynamicsProvider = () => { + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", id: "channel-fuse", version: "1", state_schema_version: "v1", + initialize() {}, restore(value) { state = structuredClone(value); }, snapshot() { return structuredClone(state); }, + observe() { return { channels: Array.from({ length: ${DYNAMICS_LIMITS.observation_channels + 1} }, (_, index) => ({ + components: { value: index }, sense_address: "sense:counter", subject_address: "object:o" + index + })) }; }, + step(input) { return { action_results: [], events: [], tick: input.tick }; } + }; +}; +`, /channel limit/u], [` +export const createDynamicsProvider = () => { + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", id: "component-fuse", version: "1", state_schema_version: "v1", + initialize() {}, restore(value) { state = structuredClone(value); }, snapshot() { return structuredClone(state); }, + observe() { return { channels: [{ + components: Object.fromEntries(Array.from({ length: ${DYNAMICS_LIMITS.observation_components_per_channel + 1} }, (_, index) => ["v" + index, index])), + sense_address: "sense:counter", subject_address: "object:test" + }] }; }, + step(input) { return { action_results: [], events: [], tick: input.tick }; } + }; +}; +`, /component limit/u]] as const) { + const project = await createDynamicsTestProject(source); + try { + const session = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(session); + const before = session.snapshot(); + assert.throws(() => session.observe(counterObservationRequest), expected); + assert.deepEqual(session.snapshot(), before); + } finally { + await removeDynamicsTestProject(project); + } + } +}); diff --git a/src/dynamics/sessionProviderBoundary.ts b/src/dynamics/sessionProviderBoundary.ts new file mode 100644 index 0000000..cb6fefe --- /dev/null +++ b/src/dynamics/sessionProviderBoundary.ts @@ -0,0 +1,39 @@ +import { + canonicalDynamicsJson, + cloneDynamicsJson, +} from "./canonicalJson.js"; +import type { DynamicsJsonValue, DynamicsProvider } from "./types.js"; + +const isPromiseLike = (value: unknown): value is PromiseLike => + typeof value === "object" && value !== null + && typeof (value as { then?: unknown }).then === "function"; + +export const requireSynchronous = ( + value: T, + method: string, +): Exclude> => { + if (isPromiseLike(value)) throw new Error(`dynamics provider ${method}() must be synchronous`); + return value as Exclude>; +}; + +export const sameDynamicsJson = (left: unknown, right: unknown): boolean => + canonicalDynamicsJson(left) === canonicalDynamicsJson(right); + +export const restoreProviderExactly = ( + provider: DynamicsProvider, + snapshot: DynamicsJsonValue, +): void => { + requireSynchronous(provider.restore(cloneDynamicsJson(snapshot)), "restore"); + const restored = cloneDynamicsJson( + requireSynchronous(provider.snapshot(), "snapshot"), + "dynamics provider snapshot", + ); + if (!sameDynamicsJson(restored, snapshot)) { + throw new Error("dynamics provider restore() did not reproduce the requested snapshot"); + } +}; + +export const describeRollbackFailure = (error: unknown): string => { + if (error instanceof Error) return error.stack ?? error.message; + return String(error); +}; diff --git a/src/dynamics/sessionSnapshotEquality.test.ts b/src/dynamics/sessionSnapshotEquality.test.ts new file mode 100644 index 0000000..627d340 --- /dev/null +++ b/src/dynamics/sessionSnapshotEquality.test.ts @@ -0,0 +1,69 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { canonicalDynamicsJson } from "./canonicalJson.js"; +import { dynamicsActionIdempotencyRecordCodeUnits } from "./actionRetention.js"; +import { DYNAMICS_ACTION_RETENTION_LIMITS, DYNAMICS_LIMITS } from "./limits.js"; +import { sameDynamicsSessionSnapshot } from "./sameDynamicsSessionSnapshot.js"; +import { parseDynamicsSessionSnapshot } from "./snapshotValidation.js"; +import type { DynamicsSessionSnapshot } from "./types.js"; + +const snapshotWithIngress = (changed = false): DynamicsSessionSnapshot => ({ + accepted_action_sequences: { floor: 1, above_floor: [] }, + action_ingress: Array.from({ length: DYNAMICS_ACTION_RETENTION_LIMITS.records }, (_, index) => { + const suffix = index.toString().padStart(4, "0"); + const actId = `${"\0".repeat(DYNAMICS_LIMITS.identifier_code_units - suffix.length)}${suffix}`; + return { + act_id: actId, + at_tick: 1, + attempt_sha256: index.toString(16).padStart(64, "0"), + principal_id: "\0".repeat(DYNAMICS_LIMITS.identifier_code_units), + retained_at_tick: 0, + receipt: { act_id: actId, apply_tick: 0, code: "wrong_tick", queued: false }, + }; + }), + action_ingress_floor: 1, + action_ingress_ordinal: DYNAMICS_ACTION_RETENTION_LIMITS.records, + next_action_sequence: 1, + next_event_sequence: 1, + next_tick: 0, + pending_actions: [], + provider_state: { value: changed ? 1 : 0 }, + provenance: { + api_version: "simfile.dynamics-provider.v1", + config_sha256: "0".repeat(64), + module: "test", + module_sha256: "1".repeat(64), + node_version: "test", + numeric_model: "ieee754-binary64", + provider_dependencies: {}, + provider_id: "test", + provider_version: "1", + state_schema_version: "v1", + }, + resolved_action_sequences: { floor: 1, above_floor: [] }, + seed: "seed", + sim_seconds_per_tick: 1, + version: "simfile.dynamics-snapshot.v1", +}); + +test("compares large valid session snapshots without the generic JSON ceiling", () => { + const equal = snapshotWithIngress(); + const different = snapshotWithIngress(true); + assert.deepEqual(parseDynamicsSessionSnapshot(equal), equal); + const idCodeUnitsLowerBound = equal.action_ingress.reduce( + (total, record) => total + record.act_id.length + record.receipt.act_id.length, + 0, + ); + assert.ok(idCodeUnitsLowerBound >= DYNAMICS_LIMITS.json_code_units); + assert.ok(equal.action_ingress.reduce((total, record) => total + dynamicsActionIdempotencyRecordCodeUnits(record), 0) > DYNAMICS_LIMITS.json_code_units); + assert.throws(() => canonicalDynamicsJson(equal), /json_code_units|json nodes|JSON/u); + assert.equal(sameDynamicsSessionSnapshot(equal, snapshotWithIngress()), true); + assert.equal(sameDynamicsSessionSnapshot(equal, different), false); + const providerStateMutation = snapshotWithIngress(); + providerStateMutation.provider_state = { value: 2 }; + assert.equal(sameDynamicsSessionSnapshot(equal, providerStateMutation), false); + const retainedIngressMutation = snapshotWithIngress(); + retainedIngressMutation.action_ingress[0]!.attempt_sha256 = "f".repeat(64); + assert.equal(sameDynamicsSessionSnapshot(equal, retainedIngressMutation), false); +}); diff --git a/src/dynamics/sessionSpatial.test.ts b/src/dynamics/sessionSpatial.test.ts new file mode 100644 index 0000000..aff6ebd --- /dev/null +++ b/src/dynamics/sessionSpatial.test.ts @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { loadDynamicsSession } from "./load.js"; +import { + createDynamicsTestProject, + removeDynamicsTestProject, + tinyProviderSource +} from "./testSupport.test-helper.js"; + +/** + * The session half of the optional `spatial()` seam. `spatial()` is a THIRD + * projection of provider state, alongside `snapshot()` (whose round-trip the + * session verifies) and `observe()` (which is grant-gated), so the host checks + * everything it can: the call may not mutate, may not be async, and its output + * must parse. A provider that omits it stays valid and simply records nothing. + */ +const withSpatial = (body: string): string => tinyProviderSource().replace( + " snapshot() {", + ` spatial() {\n${body}\n },\n snapshot() {` +); + +const session = async (providerSource: string) => { + const project = await createDynamicsTestProject(providerSource); + const loaded = await loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }); + assert.ok(loaded); + return { loaded, project }; +}; + +describe("DynamicsSession.spatial", () => { + it("returns undefined for a provider that declares no projection", async () => { + const { loaded, project } = await session(tinyProviderSource()); + try { + assert.equal(loaded.spatial(), undefined); + // Degrading must not close the session or disturb the tick counter. + loaded.step(); + assert.equal(loaded.nextTick, 1); + assert.equal(loaded.spatial(), undefined); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("projects the provider's current state and tracks it across steps", async () => { + const { loaded, project } = await session(withSpatial( + ` return { bounds: { max: [8, 8], min: [-8, -8] }, + objects: [{ id: "object:counter", position: [state.value, state.last_tick], + velocity: [1, 0] }] };` + )); + try { + assert.deepEqual(loaded.spatial(), { + bounds: { max: [8, 8], min: [-8, -8] }, + objects: [{ id: "object:counter", position: [2, -1], velocity: [1, 0] }] + }); + loaded.step(); + assert.deepEqual(loaded.spatial()?.objects[0]?.position, [2, 0]); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("rejects a projection that mutates state, and rolls the provider back", async () => { + const { loaded, project } = await session(withSpatial( + ` state.value += 1; + return { objects: [{ id: "object:counter", position: [state.value, 0], velocity: [0, 0] }] };` + )); + try { + assert.throws(() => loaded.spatial(), /spatial\(\) must not mutate state/u); + // Rolled back: the mutation the projection attempted is not observable. + assert.equal(loaded.observe({ + observer: "agent:red", + principal_id: "moltnet:red", + sense_addresses: ["sense:counter"] + }).channels[0]?.components.value, 2); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("rejects an invalid projection rather than recording it", async () => { + const { loaded, project } = await session(withSpatial( + ` return { objects: [{ id: "object:counter", position: [NaN, 0], velocity: [0, 0] }] };` + )); + try { + assert.throws(() => loaded.spatial(), /must be a finite number/u); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("rejects an asynchronous projection at load, before any tick runs", async () => { + const project = await createDynamicsTestProject( + tinyProviderSource().replace(" snapshot() {", + " async spatial() { return { objects: [] }; },\n snapshot() {") + ); + try { + await assert.rejects( + loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }), + /spatial\(\) must be synchronous/u + ); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("rejects a non-function spatial at load", async () => { + const project = await createDynamicsTestProject( + tinyProviderSource().replace(" snapshot() {", " spatial: 7,\n snapshot() {") + ); + try { + await assert.rejects( + loadDynamicsSession(project.simfile, { simfilePath: project.simfilePath }), + /spatial\(\) must be a function/u + ); + } finally { + await removeDynamicsTestProject(project); + } + }); + + it("agrees with a projection taken from restore(snapshot()) — the seam's honesty check", async () => { + // Without this, `spatial()` could animate a match that never happened + // while every artifact hash still verified: nothing else forces the + // projection to agree with the state the record actually seals. + const { loaded, project } = await session(withSpatial( + ` return { objects: [{ id: "object:counter", + position: [state.value, state.last_tick], velocity: [0, 0] }] };` + )); + try { + loaded.step(); + loaded.step(); + const live = loaded.spatial(); + const snapshot = loaded.snapshot(); + loaded.restore(JSON.parse(JSON.stringify(snapshot)) as unknown); + assert.deepEqual(loaded.spatial(), live); + } finally { + await removeDynamicsTestProject(project); + } + }); +}); diff --git a/src/dynamics/sessionValues.ts b/src/dynamics/sessionValues.ts new file mode 100644 index 0000000..ac44e01 --- /dev/null +++ b/src/dynamics/sessionValues.ts @@ -0,0 +1,55 @@ +import { cloneDynamicsJsonObject } from "./canonicalJson.js"; +import type { + DynamicsActionAttempt, + DynamicsActionIngressRecord, + DynamicsActionQueueReceipt, + DynamicsCommand, + DynamicsJsonValue, + DynamicsQueuedAction +} from "./types.js"; + +/** + * Value copying and freezing for the dynamics session: the defensive copies + * that keep host-retained action records immutable from a provider's reach, + * and the frozen, identity-free command a provider actually sees. + * + * Extracted from `session.ts` unchanged (behavior-preserving) to keep that + * file under the repo's 400-line ceiling. The deep freeze is load-bearing: a + * provider must not be able to mutate the input the host has already retained. + */ + +export const cloneReceipt = (receipt: DynamicsActionQueueReceipt): DynamicsActionQueueReceipt => ({ ...receipt }); + +export const cloneAttempt = (attempt: DynamicsActionAttempt): DynamicsActionAttempt => ({ + ...attempt, + input: cloneDynamicsJsonObject(attempt.input, "dynamics action attempt.input") +}); + +export const cloneQueuedAction = (action: DynamicsQueuedAction): DynamicsQueuedAction => ({ + ...cloneAttempt(action), + sequence: action.sequence +}); + +export const cloneIngressRecord = (record: DynamicsActionIngressRecord): DynamicsActionIngressRecord => ({ + attempt: cloneAttempt(record.attempt), + receipt: cloneReceipt(record.receipt) +}); + +export const freezeJson = (value: T): T => { + if (Array.isArray(value)) for (const entry of value) freezeJson(entry); + else if (typeof value === "object" && value !== null) { + for (const entry of Object.values(value)) freezeJson(entry); + } + return Object.freeze(value); +}; + +export const providerCommand = (action: DynamicsQueuedAction): DynamicsCommand => { + const input = freezeJson(cloneDynamicsJsonObject(action.input, "dynamics command.input")); + return Object.freeze({ + action: action.action, + actor: action.actor, + input, + sequence: action.sequence, + target: action.target + }); +}; diff --git a/src/dynamics/snapshotValidation.ts b/src/dynamics/snapshotValidation.ts new file mode 100644 index 0000000..185c4da --- /dev/null +++ b/src/dynamics/snapshotValidation.ts @@ -0,0 +1,254 @@ +import { canonicalDynamicsJson, cloneDynamicsJson, cloneDynamicsJsonObject } from "./canonicalJson.js"; +import { + digestDynamicsActionAttempt, + dynamicsActionIdempotencyRecordCodeUnits, + dynamicsActionKey +} from "./actionRetention.js"; +import { DYNAMICS_ACTION_RETENTION_LIMITS, DYNAMICS_LIMITS } from "./limits.js"; +import { + DYNAMICS_SNAPSHOT_VERSION, + type DynamicsActionIdempotencyRecord, + type DynamicsActionIngressRecord, + type DynamicsActionQueueReceipt, + type DynamicsActionSequenceWatermark, + type DynamicsQueuedAction, + type DynamicsSessionSnapshot +} from "./types.js"; +import { parseDynamicsActionAttempt, parseDynamicsProvenance } from "./validation.js"; + +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); + +const assertOnlyKeys = (value: Record, allowed: readonly string[], path: string): void => { + const allowedSet = new Set(allowed); + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string" || !allowedSet.has(key)) throw new Error(`${path} contains unknown field ${String(key)}`); + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) throw new Error(`${path}.${key} must be an enumerable data value`); + } +}; + +const nonEmptyString = (value: unknown, path: string): string => { + if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path} must be a non-empty string`); + cloneDynamicsJson(value, path); + if (value.length > DYNAMICS_LIMITS.identifier_code_units) throw new Error(`${path} exceeds the dynamics identifier code-unit limit`); + return value; +}; + +const integer = (value: unknown, path: string, minimum: number): number => { + if (!Number.isSafeInteger(value) || (value as number) < minimum) throw new Error(`${path} must be a safe integer >= ${minimum}`); + return Object.is(value, -0) ? 0 : value as number; +}; + +const positiveFinite = (value: unknown, path: string): number => { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) throw new Error(`${path} must be a positive finite number`); + return value; +}; + +const isDenseArray = (value: unknown[]): boolean => { + for (let index = 0; index < value.length; index += 1) if (!Object.hasOwn(value, index)) return false; + for (const key of Reflect.ownKeys(value)) { + if (key === "length") continue; + if (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= value.length) return false; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) return false; + } + return true; +}; + +const parseSequenceArray = (value: unknown, path: string, minimum: number): number[] => { + if (!Array.isArray(value) || value.length > DYNAMICS_LIMITS.retained_action_records) throw new Error(`${path} exceeds the retained action limit`); + if (!isDenseArray(value)) throw new Error(`${path} must not be sparse`); + const sequences = value.map((entry, index) => integer(entry, `${path}[${index}]`, minimum)); + if (new Set(sequences).size !== sequences.length) throw new Error(`${path} must be unique`); + return sequences.sort((left, right) => left - right); +}; + +const parseSequenceWatermark = (value: unknown, path: string): DynamicsActionSequenceWatermark => { + if (!isRecord(value)) throw new Error(`${path} must be an object`); + assertOnlyKeys(value, ["above_floor", "floor"], path); + const floor = integer(value.floor, `${path}.floor`, 1); + const above = parseSequenceArray(value.above_floor, `${path}.above_floor`, floor + 1); + if (above.some((sequence) => sequence <= floor)) throw new Error(`${path}.above_floor must be above floor`); + return { floor, above_floor: above }; +}; + +const compareText = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0; + +/** Canonical durable ingress order used by run records and compatibility projections. */ +export const compareDynamicsIngressRecords = (left: DynamicsActionIngressRecord, right: DynamicsActionIngressRecord): number => { + if (left.receipt.queued && right.receipt.queued) return (left.receipt.sequence as number) - (right.receipt.sequence as number); + if (left.receipt.queued !== right.receipt.queued) return left.receipt.queued ? -1 : 1; + return left.receipt.apply_tick - right.receipt.apply_tick || left.attempt.at_tick - right.attempt.at_tick + || compareText(dynamicsActionKey(left.attempt), dynamicsActionKey(right.attempt)); +}; + +/** Canonical serialized size for complete durable ingress evidence. */ +export const dynamicsIngressRecordCodeUnits = (record: DynamicsActionIngressRecord): number => + canonicalDynamicsJson(record, "dynamics action ingress record").length; + +const parseReceipt = (value: unknown, actId: string, atTick: number, path: string): DynamicsActionQueueReceipt => { + if (!isRecord(value) || typeof value.queued !== "boolean") throw new Error(`${path} must declare queued as a boolean`); + assertOnlyKeys(value, ["act_id", "apply_tick", "code", "queued", "sequence"], path); + const receipt: DynamicsActionQueueReceipt = { + act_id: nonEmptyString(value.act_id, `${path}.act_id`), + apply_tick: integer(value.apply_tick, `${path}.apply_tick`, 0), + queued: value.queued + }; + if (receipt.act_id !== actId) throw new Error(`${path}.act_id must equal its retained identity`); + if (receipt.queued) { + if (value.code !== undefined) throw new Error(`${path} queued receipt cannot declare code`); + receipt.sequence = integer(value.sequence, `${path}.sequence`, 1); + if (receipt.apply_tick !== atTick) throw new Error(`${path}.apply_tick must equal the queued attempt tick`); + } else { + if (value.code !== "wrong_tick" || value.sequence !== undefined) throw new Error(`${path} stored rejection must be a sequence-free wrong_tick receipt`); + if (receipt.apply_tick === atTick) throw new Error(`${path} wrong_tick receipt must disagree with the attempted tick`); + receipt.code = "wrong_tick"; + } + return receipt; +}; + +export const compareDynamicsActionIdempotencyRecords = (left: DynamicsActionIdempotencyRecord, right: DynamicsActionIdempotencyRecord): number => { + if (left.receipt.queued && right.receipt.queued) return (left.receipt.sequence as number) - (right.receipt.sequence as number); + if (left.receipt.queued !== right.receipt.queued) return left.receipt.queued ? -1 : 1; + return left.retained_at_tick - right.retained_at_tick || left.receipt.apply_tick - right.receipt.apply_tick + || left.at_tick - right.at_tick + || compareText(dynamicsActionKey(left), dynamicsActionKey(right)); +}; + +const parseIngress = (value: unknown): DynamicsActionIdempotencyRecord[] => { + if (!Array.isArray(value) || value.length > DYNAMICS_ACTION_RETENTION_LIMITS.records) throw new Error("dynamics snapshot.action_ingress exceeds the retained ingress limit"); + if (!isDenseArray(value)) throw new Error("dynamics snapshot.action_ingress must not be sparse"); + const seen = new Set(); + let retainedCodeUnits = 0; + const records = value.map((entry, index) => { + const path = `dynamics snapshot.action_ingress[${index}]`; + if (!isRecord(entry)) throw new Error(`${path} must be an object`); + assertOnlyKeys(entry, ["act_id", "at_tick", "attempt_sha256", "principal_id", "retained_at_tick", "receipt"], path); + const record: DynamicsActionIdempotencyRecord = { + act_id: nonEmptyString(entry.act_id, `${path}.act_id`), + at_tick: integer(entry.at_tick, `${path}.at_tick`, 0), + attempt_sha256: typeof entry.attempt_sha256 === "string" && SHA256_PATTERN.test(entry.attempt_sha256) + ? entry.attempt_sha256 : (() => { throw new Error(`${path}.attempt_sha256 must be a SHA-256 digest`); })(), + principal_id: nonEmptyString(entry.principal_id, `${path}.principal_id`), + retained_at_tick: integer(entry.retained_at_tick, `${path}.retained_at_tick`, 0), + receipt: {} as DynamicsActionQueueReceipt + }; + record.receipt = parseReceipt(entry.receipt, record.act_id, record.at_tick, `${path}.receipt`); + const key = dynamicsActionKey(record); + if (seen.has(key)) throw new Error("dynamics snapshot.action_ingress keys must be unique"); + seen.add(key); + retainedCodeUnits += dynamicsActionIdempotencyRecordCodeUnits(record); + if (retainedCodeUnits > DYNAMICS_ACTION_RETENTION_LIMITS.code_units + || retainedCodeUnits > DYNAMICS_LIMITS.retained_action_code_units) { + throw new Error("dynamics snapshot.action_ingress exceeds the retained ingress code-unit limit"); + } + return record; + }); + return records.sort(compareDynamicsActionIdempotencyRecords); +}; + +const parsePending = (value: unknown): DynamicsQueuedAction[] => { + if (!Array.isArray(value) || value.length > DYNAMICS_LIMITS.actions_per_tick) throw new Error("dynamics snapshot.pending_actions exceeds the pending action limit"); + if (!isDenseArray(value)) throw new Error("dynamics snapshot.pending_actions must not be sparse"); + const pending = value.map((entry, index) => { + const path = `dynamics snapshot.pending_actions[${index}]`; + const record = cloneDynamicsJsonObject(entry, path); + const sequence = integer(record.sequence, `${path}.sequence`, 1); + const { sequence: _ignored, ...attempt } = record; + return { ...parseDynamicsActionAttempt(attempt), sequence }; + }); + if (new Set(pending.map((entry) => entry.sequence)).size !== pending.length) throw new Error("dynamics snapshot.pending action sequences must be unique"); + return pending.sort((left, right) => left.sequence - right.sequence); +}; + +const watermarkHas = (watermark: DynamicsActionSequenceWatermark, sequence: number): boolean => + sequence < watermark.floor || watermark.above_floor.includes(sequence); + +const assertActionInvariants = (snapshot: { + accepted: DynamicsActionSequenceWatermark; + ingress: DynamicsActionIdempotencyRecord[]; + ingressFloor: number; + nextActionSequence: number; + nextTick: number; + pending: DynamicsQueuedAction[]; + resolved: DynamicsActionSequenceWatermark; +}): void => { + if (snapshot.ingressFloor > snapshot.nextActionSequence) throw new Error("dynamics snapshot retained sequence floor exceeds next_action_sequence"); + const queued = snapshot.ingress.filter((record) => record.receipt.queued); + const queuedSequences = queued.map((record) => record.receipt.sequence as number); + if (queuedSequences.length !== snapshot.nextActionSequence - snapshot.ingressFloor + || queuedSequences.some((sequence, index) => sequence !== snapshot.ingressFloor + index)) { + throw new Error("dynamics snapshot queued action sequences must be contiguous from the retained floor"); + } + if (snapshot.pending.length !== queued.length + || snapshot.pending.some((action, index) => action.sequence !== queuedSequences[index])) { + throw new Error("dynamics snapshot pending actions must equal retained queued receipts"); + } + const ingressByKey = new Map(snapshot.ingress.map((record) => [dynamicsActionKey(record), record])); + for (const action of snapshot.pending) { + if (action.at_tick !== snapshot.nextTick) throw new Error("dynamics snapshot pending actions must target next_tick"); + const retained = ingressByKey.get(dynamicsActionKey(action)); + if (!retained || retained.receipt.sequence !== action.sequence) throw new Error("dynamics snapshot pending action must correspond to its ingress receipt"); + const { sequence: _ignored, ...attempt } = action; + if (digestDynamicsActionAttempt(attempt) !== retained.attempt_sha256) throw new Error("dynamics snapshot pending action digest differs from its ingress digest"); + } + for (const record of snapshot.ingress) { + if (record.retained_at_tick > snapshot.nextTick) { + throw new Error("dynamics snapshot retained ingress cannot be from the future"); + } + if (record.receipt.queued) { + if (record.retained_at_tick !== snapshot.nextTick || record.receipt.apply_tick !== snapshot.nextTick) { + throw new Error("dynamics snapshot queued ingress must belong to next_tick"); + } + } else if (record.receipt.apply_tick < record.retained_at_tick || record.receipt.apply_tick > snapshot.nextTick) { + throw new Error("dynamics snapshot rejected ingress receipt must belong to next_tick or its prior retry tick"); + } + } + if (snapshot.resolved.floor !== snapshot.ingressFloor || snapshot.resolved.above_floor.length !== 0) { + throw new Error("dynamics snapshot resolved sequence watermark must reach the retained floor without exceptions"); + } + if (snapshot.accepted.floor > snapshot.resolved.floor) throw new Error("dynamics snapshot accepted sequence floor exceeds the resolved floor"); + for (const sequence of snapshot.accepted.above_floor) { + if (!watermarkHas(snapshot.resolved, sequence)) throw new Error("dynamics snapshot accepted sequence is not resolved"); + } +}; + +export const parseDynamicsSessionSnapshot = (value: unknown): DynamicsSessionSnapshot => { + if (!isRecord(value) || value.version !== DYNAMICS_SNAPSHOT_VERSION) throw new Error("invalid simfile dynamics snapshot version"); + assertOnlyKeys(value, [ + "accepted_action_sequences", "action_ingress", "action_ingress_floor", "action_ingress_ordinal", + "next_action_sequence", "next_event_sequence", "next_tick", "pending_actions", "provider_state", + "provenance", "resolved_action_sequences", "seed", "sim_seconds_per_tick", "version" + ], "dynamics snapshot"); + const snapshot = { + accepted: parseSequenceWatermark(value.accepted_action_sequences, "dynamics snapshot.accepted_action_sequences"), + ingress: parseIngress(value.action_ingress), + ingressFloor: integer(value.action_ingress_floor, "dynamics snapshot.action_ingress_floor", 1), + ingressOrdinal: integer(value.action_ingress_ordinal, "dynamics snapshot.action_ingress_ordinal", 0), + nextActionSequence: integer(value.next_action_sequence, "dynamics snapshot.next_action_sequence", 1), + nextEventSequence: integer(value.next_event_sequence, "dynamics snapshot.next_event_sequence", 1), + nextTick: integer(value.next_tick, "dynamics snapshot.next_tick", 0), + pending: parsePending(value.pending_actions), + resolved: parseSequenceWatermark(value.resolved_action_sequences, "dynamics snapshot.resolved_action_sequences") + }; + assertActionInvariants(snapshot); + return { + accepted_action_sequences: snapshot.accepted, + action_ingress: snapshot.ingress, + action_ingress_floor: snapshot.ingressFloor, + action_ingress_ordinal: snapshot.ingressOrdinal, + next_action_sequence: snapshot.nextActionSequence, + next_event_sequence: snapshot.nextEventSequence, + next_tick: snapshot.nextTick, + pending_actions: snapshot.pending, + provider_state: cloneDynamicsJson(value.provider_state, "dynamics snapshot.provider_state"), + provenance: parseDynamicsProvenance(value.provenance), + resolved_action_sequences: snapshot.resolved, + seed: cloneDynamicsJson(nonEmptyString(value.seed, "dynamics snapshot.seed"), "dynamics snapshot.seed") as string, + sim_seconds_per_tick: positiveFinite(value.sim_seconds_per_tick, "dynamics snapshot.sim_seconds_per_tick"), + version: DYNAMICS_SNAPSHOT_VERSION + }; +}; diff --git a/src/dynamics/spatialValidation.test.ts b/src/dynamics/spatialValidation.test.ts new file mode 100644 index 0000000..e05b81e --- /dev/null +++ b/src/dynamics/spatialValidation.test.ts @@ -0,0 +1,115 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { DYNAMICS_LIMITS } from "./limits.js"; +import { parseDynamicsSpatialFrame } from "./spatialValidation.js"; + +const object = (id: string) => ({ id, position: [1, 2], velocity: [3, 4] }); + +describe("parseDynamicsSpatialFrame", () => { + it("accepts a bounded frame and returns exactly the recorded fields", () => { + assert.deepEqual( + parseDynamicsSpatialFrame({ + bounds: { max: [4, 3], min: [-4, -3] }, + extra: "ignored", + objects: [{ ...object("object:a"), extra: "ignored" }] + }), + { + bounds: { max: [4, 3], min: [-4, -3] }, + objects: [{ id: "object:a", position: [1, 2], velocity: [3, 4] }] + } + ); + }); + + it("accepts a frame with no bounds and omits the key rather than inventing one", () => { + const frame = parseDynamicsSpatialFrame({ objects: [] }); + assert.deepEqual(frame, { objects: [] }); + assert.equal("bounds" in frame, false); + }); + + it("normalizes -0 to 0 so the in-memory frame matches its recorded bytes", () => { + // JSON.stringify(-0) emits "0". Left unnormalized, a frame holding -0 + // would disagree with the file it was written to — a byte-identity trap, + // not a rendering one. + const frame = parseDynamicsSpatialFrame({ + bounds: { max: [1, 1], min: [-0, -1] }, + objects: [{ id: "object:a", position: [-0, 0], velocity: [0, -0] }] + }); + assert.equal(Object.is(frame.objects[0]!.position[0], 0), true); + assert.equal(Object.is(frame.objects[0]!.velocity[1], 0), true); + assert.equal(Object.is(frame.bounds!.min[0], 0), true); + assert.equal(JSON.stringify(frame), JSON.stringify(JSON.parse(JSON.stringify(frame)))); + }); + + it("rejects non-finite numbers on every numeric axis", () => { + for (const bad of [Number.NaN, Number.POSITIVE_INFINITY, Number.NEGATIVE_INFINITY, "1", null]) { + assert.throws(() => parseDynamicsSpatialFrame({ + objects: [{ id: "object:a", position: [bad, 0], velocity: [0, 0] }] + }), /must be a finite number/u); + assert.throws(() => parseDynamicsSpatialFrame({ + objects: [{ id: "object:a", position: [0, 0], velocity: [0, bad] }] + }), /must be a finite number/u); + assert.throws(() => parseDynamicsSpatialFrame({ + bounds: { max: [bad, 1], min: [-1, -1] }, + objects: [] + }), /must be a finite number/u); + } + }); + + it("rejects malformed shapes rather than coercing them", () => { + assert.throws(() => parseDynamicsSpatialFrame(null), /must return an object/u); + assert.throws(() => parseDynamicsSpatialFrame([]), /must return an object/u); + assert.throws(() => parseDynamicsSpatialFrame({}), /objects must be an array/u); + assert.throws(() => parseDynamicsSpatialFrame({ objects: [1] }), /must be an object/u); + assert.throws(() => parseDynamicsSpatialFrame({ objects: [{ ...object("") }] }), + /id must be a non-empty string/u); + assert.throws(() => parseDynamicsSpatialFrame({ objects: [{ ...object(" ") }] }), + /id must be a non-empty string/u); + assert.throws(() => parseDynamicsSpatialFrame({ + objects: [{ id: "object:a", position: [1], velocity: [0, 0] }] + }), /must be a two-number array/u); + assert.throws(() => parseDynamicsSpatialFrame({ + objects: [{ id: "object:a", position: [1, 2, 3], velocity: [0, 0] }] + }), /must be a two-number array/u); + assert.throws(() => parseDynamicsSpatialFrame({ + objects: [], bounds: 3 + }), /bounds must be an object/u); + }); + + it("rejects a degenerate or inverted extent", () => { + for (const bounds of [ + { max: [0, 1], min: [0, -1] }, + { max: [1, -1], min: [-1, 1] } + ]) { + assert.throws(() => parseDynamicsSpatialFrame({ bounds, objects: [] }), + /positive-area extent/u); + } + }); + + it("rejects duplicate ids, which would make two bodies share one track", () => { + assert.throws(() => parseDynamicsSpatialFrame({ + objects: [object("object:a"), object("object:a")] + }), /duplicate ids/u); + }); + + it("bounds the object count at DYNAMICS_LIMITS.spatial_objects", () => { + const ids = (count: number) => Array.from({ length: count }, + (_unused, index) => object(`object:${index}`)); + const limit = DYNAMICS_LIMITS.spatial_objects; + assert.equal(parseDynamicsSpatialFrame({ objects: ids(limit) }).objects.length, limit); + assert.throws(() => parseDynamicsSpatialFrame({ objects: ids(limit + 1) }), + /exceeds the spatial object limit/u); + }); + + it("bounds the id length at the shared identifier limit", () => { + const id = "o".repeat(DYNAMICS_LIMITS.identifier_code_units + 1); + assert.throws(() => parseDynamicsSpatialFrame({ objects: [object(id)] }), + /exceeds the identifier limit/u); + }); + + it("names the failing path so a provider bug is locatable", () => { + assert.throws(() => parseDynamicsSpatialFrame({ + objects: [object("object:a"), { id: "object:b", position: [0, "x"], velocity: [0, 0] }] + }), /spatial\(\)\.objects\[1\]\.position\[1\] must be a finite number/u); + }); +}); diff --git a/src/dynamics/spatialValidation.ts b/src/dynamics/spatialValidation.ts new file mode 100644 index 0000000..34ef337 --- /dev/null +++ b/src/dynamics/spatialValidation.ts @@ -0,0 +1,83 @@ +import { DYNAMICS_LIMITS } from "./limits.js"; +import type { DynamicsSpatialFrame, DynamicsSpatialObject } from "./types.js"; + +/** + * Validates a provider's optional per-tick spatial projection + * (`DynamicsProvider.spatial()`), the one seam by which opaque + * `provider_state` surfaces renderable motion to generic host code. + * + * `spatial()` is a THIRD projection of provider state, alongside `snapshot()` + * (whose round-trip the session verifies) and `observe()` (which is + * grant-gated). Nothing else forces it to agree with the state the record + * seals, so everything checkable is checked here: shape, finiteness, id + * uniqueness, and a hard object ceiling. Agreement with `snapshot()` is a + * documented provider obligation, covered by a fixture test rather than by + * the host. + */ + +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value); + +/** + * Rejects NaN and infinities, and normalizes `-0` to `0`. `JSON.stringify(-0)` + * silently emits `0`, so an unnormalized `-0` would make an in-memory frame + * and its recorded bytes disagree — a byte-identity trap rather than a + * rendering one. + */ +const finiteNumber = (value: unknown, path: string): number => { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new Error(`${path} must be a finite number`); + } + return value === 0 ? 0 : value; +}; + +const pair = (value: unknown, path: string): [number, number] => { + if (!Array.isArray(value) || value.length !== 2) { + throw new Error(`${path} must be a two-number array`); + } + return [finiteNumber(value[0], `${path}[0]`), finiteNumber(value[1], `${path}[1]`)]; +}; + +const parseObject = (value: unknown, path: string): DynamicsSpatialObject => { + if (!isRecord(value)) throw new Error(`${path} must be an object`); + const id = value.id; + if (typeof id !== "string" || id.trim().length === 0) { + throw new Error(`${path}.id must be a non-empty string`); + } + if (id.length > DYNAMICS_LIMITS.identifier_code_units) { + throw new Error(`${path}.id exceeds the identifier limit`); + } + return { + id, + position: pair(value.position, `${path}.position`), + velocity: pair(value.velocity, `${path}.velocity`) + }; +}; + +export const parseDynamicsSpatialFrame = ( + value: unknown, + path = "dynamics provider spatial()" +): DynamicsSpatialFrame => { + if (!isRecord(value)) throw new Error(`${path} must return an object`); + if (!Array.isArray(value.objects)) { + throw new Error(`${path}.objects must be an array`); + } + if (value.objects.length > DYNAMICS_LIMITS.spatial_objects) { + throw new Error(`${path}.objects exceeds the spatial object limit`); + } + const objects = value.objects.map((entry, index) => + parseObject(entry, `${path}.objects[${index}]`)); + const ids = new Set(objects.map((object) => object.id)); + if (ids.size !== objects.length) { + throw new Error(`${path}.objects contains duplicate ids`); + } + + if (value.bounds === undefined) return { objects }; + if (!isRecord(value.bounds)) throw new Error(`${path}.bounds must be an object`); + const min = pair(value.bounds.min, `${path}.bounds.min`); + const max = pair(value.bounds.max, `${path}.bounds.max`); + if (!(max[0] > min[0]) || !(max[1] > min[1])) { + throw new Error(`${path}.bounds must describe a positive-area extent`); + } + return { bounds: { max, min }, objects }; +}; diff --git a/src/dynamics/testSupport.test-helper.ts b/src/dynamics/testSupport.test-helper.ts new file mode 100644 index 0000000..c264d0a --- /dev/null +++ b/src/dynamics/testSupport.test-helper.ts @@ -0,0 +1,152 @@ +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +import { parseSimfileSource } from "../schema/parse.js"; +import type { Simfile } from "../schema/model.js"; + +// Test-only authored-provider/project helpers; excluded from production emit. + +export const tinyProviderSource = (incrementScale = 1): string => ` +export const createDynamicsProvider = () => { + let state = { value: 0, last_dt_seconds: 0, last_tick: -1, sim_seconds_per_tick: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", + dependencies: { "tiny-math": "1.0.0" }, + id: "tiny-counter", + integration: { accepted_actions: ["increment"], model: "counter" }, + version: "1.0.0", + state_schema_version: "counter.v1", + initialize(context) { + state = { + value: context.config.start ?? 0, + last_dt_seconds: 0, + last_tick: -1, + sim_seconds_per_tick: context.sim_seconds_per_tick + }; + }, + observe(request) { + return { + channels: request.sense_addresses.includes("sense:counter") ? [{ + components: { + last_dt_seconds: state.last_dt_seconds, + last_tick: state.last_tick, + sim_seconds_per_tick: state.sim_seconds_per_tick, + value: state.value + }, + sense_address: "sense:counter", + subject_address: "object:counter", + unit: "count" + }] : [] + }; + }, + restore(snapshot) { + state = structuredClone(snapshot); + }, + snapshot() { + return structuredClone(state); + }, + step(input) { + const action_results = []; + const events = []; + for (const command of input.actions) { + if ( + command.action !== "increment" + || command.target !== "object:counter" + || typeof command.input.amount !== "number" + ) { + action_results.push({ + accepted: false, + code: "unsupported_action", + sequence: command.sequence + }); + continue; + } + state.value += command.input.amount * ${incrementScale}; + action_results.push({ accepted: true, sequence: command.sequence }); + events.push({ + cause_action_sequences: [command.sequence], + kind: "counter.changed", + payload: { value: state.value }, + source: command.actor, + target: command.target + }); + } + state.last_dt_seconds = input.dt_seconds; + state.last_tick = input.tick; + return { action_results, events, tick: input.tick }; + } + }; +}; +`; + +export interface DynamicsTestProject { + directory: string; + modulePath: string; + simfile: Simfile; + simfilePath: string; +} + +export const createDynamicsTestProject = async ( + providerSource = tinyProviderSource(), + configSource = "start: 2" +): Promise => { + const directory = await mkdtemp(path.join(tmpdir(), "simfile-dynamics-")); + const systems = path.join(directory, "systems"); + await mkdir(systems); + const modulePath = path.join(systems, "tiny.mjs"); + const simfilePath = path.join(directory, "Simfile"); + const simfileSource = ` +simfile_version: "0.1" +name: dynamics-test +clock: + seed: dynamics-seed + tick: 20ms + sim_per_tick: 0.5s +dynamics: + module: ./systems/tiny.mjs + config: + ${configSource.replaceAll("\n", "\n ")} +`; + const looseProviderFactoryType = + "/** @type {() => (Record & { " + + "initialize?: (context: {config: Record, [key: string]: any}) => any, " + + "observe?: (request: Record) => any, restore?: (snapshot: any) => any, " + + "snapshot?: () => any, " + + "step?: (input: {actions: any[], tick: any, [key: string]: any}) => any " + + "})} */"; + const typedProviderSource = providerSource.replace( + "export const createDynamicsProvider =", + `${looseProviderFactoryType}\nexport const createDynamicsProvider =` + ); + await writeFile(modulePath, typedProviderSource, "utf8"); + await writeFile(simfilePath, simfileSource, "utf8"); + return { + directory, + modulePath, + simfile: parseSimfileSource(simfileSource, { path: simfilePath }).simfile, + simfilePath + }; +}; + +export const removeDynamicsTestProject = async (project: DynamicsTestProject): Promise => { + await rm(project.directory, { force: true, recursive: true }); +}; + +export const counterObservationRequest = { + observer: "agent:red", + principal_id: "moltnet:red", + sense_addresses: ["sense:counter"] +} as const; + +export const counterAction = (overrides: Record = {}): Record => ({ + act_id: "act-1", + action: "increment", + actor: "agent:red", + at_tick: 0, + input: { amount: 3 }, + origin: "controller", + principal_id: "moltnet:red", + target: "object:counter", + ...overrides +}); diff --git a/src/dynamics/types.ts b/src/dynamics/types.ts new file mode 100644 index 0000000..8ef1cd1 --- /dev/null +++ b/src/dynamics/types.ts @@ -0,0 +1,324 @@ +export const DYNAMICS_PROVIDER_API_VERSION = "simfile.dynamics-provider.v1" as const; +export const DYNAMICS_SNAPSHOT_VERSION = "simfile.dynamics-snapshot.v1" as const; +export const DYNAMICS_OBSERVATION_VERSION = "simfile.numeric-observation.v1" as const; + +export type DynamicsJsonValue = + | null + | boolean + | number + | string + | DynamicsJsonValue[] + | DynamicsJsonObject; + +export interface DynamicsJsonObject { + [key: string]: DynamicsJsonValue; +} + +export type ReadonlyDynamicsJsonValue = + | null + | boolean + | number + | string + | readonly ReadonlyDynamicsJsonValue[] + | ReadonlyDynamicsJsonObject; + +export interface ReadonlyDynamicsJsonObject { + readonly [key: string]: ReadonlyDynamicsJsonValue; +} + +/** Portable identity for the trusted local module and its bound configuration. */ +export interface DynamicsProvenance { + api_version: typeof DYNAMICS_PROVIDER_API_VERSION; + config_sha256: string; + module: string; + module_sha256: string; + node_version: string; + numeric_model: "ieee754-binary64"; + provider_dependencies: Record; + provider_id: string; + provider_version: string; + state_schema_version: string; +} + +export interface DynamicsInitializeContext { + readonly config: ReadonlyDynamicsJsonObject; + readonly provenance: Readonly; + readonly seed: string; + readonly sim_seconds_per_tick: number; +} + +/** + * Host-authenticated action envelope. Agent-facing tools must not let a model + * supply actor, principal_id, at_tick, or origin directly. + */ +export interface DynamicsActionAttempt { + act_id: string; + action: string; + actor: string; + at_tick: number; + input: DynamicsJsonObject; + origin: "agentic" | "controller" | "external" | "replay"; + principal_id: string; + target: string; +} + +export interface DynamicsQueuedAction extends DynamicsActionAttempt { + sequence: number; +} + +/** Minimal immutable mechanical command; all authentication metadata stays host-side. */ +export interface DynamicsCommand { + readonly action: string; + readonly actor: string; + readonly input: ReadonlyDynamicsJsonObject; + readonly sequence: number; + readonly target: string; +} + +export interface DynamicsActionQueueReceipt { + act_id: string; + apply_tick: number; + code?: "act_id_conflict" | "wrong_tick"; + queued: boolean; + sequence?: number; +} + +/** The first canonical disposition of one principal-scoped action id. */ +export interface DynamicsActionIngressRecord { + attempt: DynamicsActionAttempt; + receipt: DynamicsActionQueueReceipt; +} + +/** Bounded identity retained after the complete ingress evidence is durable. */ +export interface DynamicsActionIdempotencyRecord { + act_id: string; + at_tick: number; + attempt_sha256: string; + principal_id: string; + retained_at_tick: number; + receipt: DynamicsActionQueueReceipt; +} + +/** Monotonic delivery identity for complete ingress evidence awaiting acknowledgment. */ +export interface DynamicsActionIngressEvidence { + ordinal: number; + record: DynamicsActionIngressRecord; +} + +/** All sequences below floor are members; above_floor holds later members. */ +export interface DynamicsActionSequenceWatermark { + floor: number; + above_floor: number[]; +} + +/** A provider's mechanical decision for one command; the host stamps identity. */ +export interface DynamicsActionResolution { + accepted: boolean; + code?: string; + message?: string; + sequence: number; +} + +export interface DynamicsActionResult extends DynamicsActionResolution { + act_id: string; + action: string; + actor: string; + apply_tick: number; + origin: DynamicsActionAttempt["origin"]; + principal_id: string; + sequence: number; + target: string; +} + +/** Untrusted mechanical draft. Only the host assigns tick, ordering, and provenance. */ +export interface DynamicsEventDraft { + cause_action_sequences: number[]; + kind: string; + payload: DynamicsJsonObject; + source: string; + target: string; +} + +export interface DynamicsEvent extends DynamicsEventDraft { + event_sequence: number; + provenance: "mechanical"; + tick: number; +} + +export type DynamicsCommitmentOutcomeStatus = + | "fulfilled" + | "expired" + | "abandoned" + | "matched" + | "unmatched"; + +/** + * Untrusted terminal commitment fact supplied by a mechanics provider. + * + * A commitment MAY be addressed to another participant: one participant + * declares an expectation about the world to a second one before acting on it. + * When it is, the record names that `counterparty` and the outcome states + * whether the world afterwards matched the declared expectation (`matched`) or + * did not (`unmatched`). The outcome is always the mechanics provider's own + * derivation from observed state — never a claim made by either participant. + */ +export interface DynamicsCommitmentOutcomeDraft { + readonly commitment_id: string; + /** The participant the expectation was declared to, when it was addressed. */ + readonly counterparty?: string; + readonly declaration_action_sequence: number; + readonly outcome: DynamicsCommitmentOutcomeStatus; + readonly participant: string; +} + +export interface DynamicsCommitmentOutcome + extends DynamicsCommitmentOutcomeDraft { + readonly provenance: "mechanical"; + readonly tick: number; +} + +export interface DynamicsStepInput { + readonly actions: readonly DynamicsCommand[]; + readonly dt_seconds: number; + readonly sim_time: number; + readonly tick: number; +} + +export interface DynamicsProviderStepResult { + action_results: DynamicsActionResolution[]; + commitment_outcomes?: DynamicsCommitmentOutcomeDraft[]; + events: DynamicsEventDraft[]; + tick: number; +} + +export interface DynamicsStepResult { + action_results: DynamicsActionResult[]; + commitment_outcomes?: DynamicsCommitmentOutcome[]; + events: DynamicsEvent[]; + tick: number; +} + +/** Exact sense-address grants, already resolved by the trusted host. */ +export interface DynamicsObservationRequest { + observer: string; + principal_id: string; + sense_addresses: string[]; +} + +/** Providers receive no caller identity, only the exact granted sense ports. */ +export interface DynamicsProviderObservationRequest { + readonly sense_addresses: readonly string[]; + readonly sim_time: number; + readonly tick: number; +} + +export interface DynamicsObservationChannel { + components: Record; + frame?: string; + sense_address: string; + subject_address: string; + unit?: string; +} + +export interface DynamicsProviderObservation { + channels: DynamicsObservationChannel[]; +} + +export interface DynamicsObservation extends DynamicsProviderObservation { + observer: string; + principal_id: string; + tick: number; + version: typeof DYNAMICS_OBSERVATION_VERSION; +} + +/** + * One spatial body in a provider's per-tick scene projection. Deliberately + * genre-neutral: an id, a 2D position, and a velocity — nothing that names a + * ball, a player, a room, or any other domain noun. Generic host code must be + * able to record and render this without learning a provider's vocabulary. + */ +export interface DynamicsSpatialObject { + id: string; + position: [number, number]; + /** + * World units per SECOND (not per tick). The viewer's Hermite interpolation + * multiplies this by the sample's duration in seconds + * (`web/src/viewer/spatialObjectModel.ts`), and its implicit-discontinuity + * check compares a per-second sampled speed against these endpoint speeds — + * so an under-reported velocity makes fast motion freeze and teleport. + */ + velocity: [number, number]; +} + +/** + * A provider's optional per-tick spatial projection: the one seam by which + * opaque `provider_state` surfaces renderable motion to generic host code. + * `bounds` is the constant scene extent (min/max corners) and only needs to be + * reported once per run; the host records the first one it sees. + */ +export interface DynamicsSpatialFrame { + bounds?: { max: [number, number]; min: [number, number] }; + objects: DynamicsSpatialObject[]; +} + +/** + * A synchronous trusted-code mechanics provider. Provider modules execute in + * the host process with no sandbox. The executed artifact digest seals bundled + * project and package code; the separate build receipt records its complete + * source closure and allowlisted runtime externals. + * + * All behavior-affecting mutable state must live in the provider instance and + * round-trip through snapshot()/restore(); mutable module-global or transitive + * singleton state is forbidden. Each load freshly evaluates the complete + * bundle. observe() and snapshot() must be pure. Provider behavior must be + * deterministic from initialize/step inputs alone: use the supplied seed and + * simulated time, never Date, Math.random, hidden async work, + * network/filesystem state, or other external side effects. + */ +export interface DynamicsProvider { + api_version: typeof DYNAMICS_PROVIDER_API_VERSION; + dependencies?: Record; + id: string; + integration?: ReadonlyDynamicsJsonObject; + state_schema_version: string; + version: string; + initialize(context: DynamicsInitializeContext): void; + observe(request: DynamicsProviderObservationRequest): DynamicsProviderObservation; + restore(snapshot: DynamicsJsonValue): void; + snapshot(): DynamicsJsonValue; + /** + * Optional per-tick scene projection, used only to record a renderable + * motion track. Must be pure and deterministic like `observe()` — the host + * enforces that by snapshot-comparing around the call. A provider that omits + * it simply records no motion: the host degrades, never throws, and never + * fabricates positions. Optional and absence-degrading by design, so adding + * it needs no `api_version` bump. + */ + spatial?(): DynamicsSpatialFrame; + step(input: DynamicsStepInput): DynamicsProviderStepResult; +} + +export interface DynamicsProviderModule { + createDynamicsProvider(): DynamicsProvider; +} + +/** + * Checkpoint for the dynamics session only. Whole-world checkpoints must also + * preserve Simfile variables, presence, rule/condition state, and ledger cursors. + */ +export interface DynamicsSessionSnapshot { + accepted_action_sequences: DynamicsActionSequenceWatermark; + action_ingress: DynamicsActionIdempotencyRecord[]; + action_ingress_floor: number; + action_ingress_ordinal: number; + next_action_sequence: number; + next_event_sequence: number; + next_tick: number; + pending_actions: DynamicsQueuedAction[]; + provider_state: DynamicsJsonValue; + provenance: DynamicsProvenance; + resolved_action_sequences: DynamicsActionSequenceWatermark; + seed: string; + sim_seconds_per_tick: number; + version: typeof DYNAMICS_SNAPSHOT_VERSION; +} diff --git a/src/dynamics/validation.ts b/src/dynamics/validation.ts new file mode 100644 index 0000000..afea379 --- /dev/null +++ b/src/dynamics/validation.ts @@ -0,0 +1,352 @@ +import { + DYNAMICS_OBSERVATION_VERSION, + DYNAMICS_PROVIDER_API_VERSION, + type DynamicsActionAttempt, + type DynamicsActionResolution, + type DynamicsCommand, + type DynamicsEventDraft, + type DynamicsJsonObject, + type DynamicsObservation, + type DynamicsObservationChannel, + type DynamicsObservationRequest, + type DynamicsProvider, + type DynamicsProviderStepResult, + type DynamicsProvenance +} from "./types.js"; +import { cloneDynamicsJson, cloneDynamicsJsonObject } from "./canonicalJson.js"; +import { parseDynamicsCommitmentOutcomeDrafts } from "./commitmentOutcomes.js"; +import { DYNAMICS_LIMITS } from "./limits.js"; + +const ADDRESS_PATTERN = /^[a-z][a-z0-9_-]*(?::[a-z][a-z0-9_.-]*)+$/u; +const NAME_PATTERN = /^[a-z][a-z0-9_-]*(?:[.:][a-z][a-z0-9_-]*)*$/u; +const SHA256_PATTERN = /^[a-f0-9]{64}$/u; +const isRecord = (value: unknown): value is Record => + typeof value === "object" && value !== null && !Array.isArray(value) + && (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null); + +const assertOnlyKeys = (value: Record, allowed: readonly string[], path: string): void => { + const allowedSet = new Set(allowed); + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string" || !allowedSet.has(key)) { + throw new Error(`${path} contains unknown field ${String(key)}`); + } + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) { + throw new Error(`${path}.${key} must be an enumerable data value`); + } + } +}; + +const boundedString = (value: unknown, path: string, limit: number): string => { + if (typeof value !== "string" || value.trim().length === 0) { + throw new Error(`${path} must be a non-empty string`); + } + cloneDynamicsJson(value, path); + if (value.length > limit) throw new Error(`${path} exceeds the ${limit} code-unit limit`); + return value; +}; + +const nonEmptyString = (value: unknown, path: string): string => + boundedString(value, path, DYNAMICS_LIMITS.identifier_code_units); + +const namedString = (value: unknown, path: string): string => { + const parsed = nonEmptyString(value, path); + if (!NAME_PATTERN.test(parsed)) { + throw new Error(`${path} must be a canonical name`); + } + return parsed; +}; + +const addressString = (value: unknown, path: string): string => { + const parsed = nonEmptyString(value, path); + if (!ADDRESS_PATTERN.test(parsed)) { + throw new Error(`${path} must be a canonical address such as object:ball`); + } + return parsed; +}; + +const nonNegativeInteger = (value: unknown, path: string): number => { + if (!Number.isSafeInteger(value) || (value as number) < 0) { + throw new Error(`${path} must be a non-negative safe integer`); + } + return Object.is(value, -0) ? 0 : value as number; +}; + +const positiveInteger = (value: unknown, path: string): number => { + if (!Number.isSafeInteger(value) || (value as number) <= 0) { + throw new Error(`${path} must be a positive safe integer`); + } + return value as number; +}; + +const sha256String = (value: unknown, path: string): string => { + if (typeof value !== "string" || !SHA256_PATTERN.test(value)) { + throw new Error(`${path} must be a SHA-256 digest`); + } + return value; +}; + +const codePointCompare = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +export const parseDynamicsSeed = (value: unknown): string => { + if (typeof value !== "string" || value.length === 0) { + throw new Error("dynamics seed must be a non-empty string"); + } + cloneDynamicsJson(value, "dynamics seed"); + if (value.length > DYNAMICS_LIMITS.identifier_code_units) { + throw new Error("dynamics seed exceeds the dynamics identifier code-unit limit"); + } + return value; +}; + +export const parseDynamicsActionAttempt = (value: unknown): DynamicsActionAttempt => { + const record = cloneDynamicsJsonObject(value, "dynamics action attempt"); + assertOnlyKeys(record, ["act_id", "action", "actor", "at_tick", "input", "origin", "principal_id", "target"], "dynamics action attempt"); + const origin = record.origin; + if (origin !== "agentic" && origin !== "controller" && origin !== "external" && origin !== "replay") { + throw new Error("dynamics action attempt.origin is invalid"); + } + return { + act_id: nonEmptyString(record.act_id, "dynamics action attempt.act_id"), + action: namedString(record.action, "dynamics action attempt.action"), + actor: addressString(record.actor, "dynamics action attempt.actor"), + at_tick: nonNegativeInteger(record.at_tick, "dynamics action attempt.at_tick"), + input: cloneDynamicsJsonObject(record.input, "dynamics action attempt.input"), + origin, + principal_id: nonEmptyString(record.principal_id, "dynamics action attempt.principal_id"), + target: addressString(record.target, "dynamics action attempt.target") + }; +}; + +const parseResolution = (value: unknown, path: string): DynamicsActionResolution => { + if (!isRecord(value) || typeof value.accepted !== "boolean") { + throw new Error(`${path} must declare accepted as a boolean`); + } + assertOnlyKeys(value, ["accepted", "code", "message", "sequence"], path); + const code = value.code === undefined ? undefined : namedString(value.code, `${path}.code`); + if (value.accepted && code !== undefined) throw new Error(`${path} accepted result cannot declare code`); + if (!value.accepted && code === undefined) throw new Error(`${path} rejected result must declare code`); + return { + accepted: value.accepted, + ...(code ? { code } : {}), + ...(value.message === undefined ? {} : { + message: boundedString(value.message, `${path}.message`, DYNAMICS_LIMITS.message_code_units) + }), + sequence: positiveInteger(value.sequence, `${path}.sequence`) + }; +}; + +const parseEventDraft = (value: unknown, path: string, nextActionSequence: number): DynamicsEventDraft => { + if (!isRecord(value)) throw new Error(`${path} must be an object`); + assertOnlyKeys(value, ["cause_action_sequences", "kind", "payload", "source", "target"], path); + if ( + !Array.isArray(value.cause_action_sequences) + || value.cause_action_sequences.length > DYNAMICS_LIMITS.causes_per_event + ) throw new Error(`${path}.cause_action_sequences exceeds the cause limit`); + const causeActionSequences = value.cause_action_sequences.map((sequence, index) => + positiveInteger(sequence, `${path}.cause_action_sequences[${index}]`)); + if (new Set(causeActionSequences).size !== causeActionSequences.length) { + throw new Error(`${path}.cause_action_sequences must be unique`); + } + for (const sequence of causeActionSequences) { + if (sequence >= nextActionSequence) throw new Error(`${path} references unknown action sequence ${sequence}`); + } + return { + cause_action_sequences: causeActionSequences, + kind: namedString(value.kind, `${path}.kind`), + payload: cloneDynamicsJsonObject(value.payload, `${path}.payload`), + source: addressString(value.source, `${path}.source`), + target: addressString(value.target, `${path}.target`) + }; +}; + +export const parseDynamicsStepResult = ( + value: unknown, + expectedTick: number, + actions: readonly DynamicsCommand[], + nextActionSequence: number +): DynamicsProviderStepResult => { + const record = cloneDynamicsJsonObject(value, "dynamics step result"); + assertOnlyKeys(record, ["action_results", "commitment_outcomes", "events", "tick"], "dynamics step result"); + if (record.tick !== expectedTick) throw new Error(`dynamics step result tick must equal ${expectedTick}`); + if (!Array.isArray(record.action_results) || record.action_results.length !== actions.length) { + throw new Error("dynamics step result must resolve every queued action exactly once"); + } + const actionResults = record.action_results.map((result, index) => { + const parsed = parseResolution(result, `dynamics step result.action_results[${index}]`); + if (parsed.sequence !== actions[index]?.sequence) { + throw new Error("dynamics action results must preserve canonical command order"); + } + return parsed; + }); + if (!Array.isArray(record.events) || record.events.length > DYNAMICS_LIMITS.events_per_tick) { + throw new Error("dynamics step result.events exceeds the per-tick event limit"); + } + const commitmentOutcomes = record.commitment_outcomes === undefined + ? undefined + : parseDynamicsCommitmentOutcomeDrafts(record.commitment_outcomes); + if (record.events.length + (commitmentOutcomes?.length ?? 0) + > DYNAMICS_LIMITS.events_per_tick) { + throw new Error("dynamics step result events and commitment outcomes exceed the per-tick event limit"); + } + return { + action_results: actionResults, + ...(commitmentOutcomes === undefined ? {} : { + commitment_outcomes: commitmentOutcomes, + }), + events: record.events.map((event, index) => parseEventDraft(event, `dynamics step result.events[${index}]`, nextActionSequence)), + tick: expectedTick + }; +}; + +export const parseDynamicsObservationRequest = (value: unknown): DynamicsObservationRequest => { + const record = cloneDynamicsJsonObject(value, "dynamics observation request"); + assertOnlyKeys(record, ["observer", "principal_id", "sense_addresses"], "dynamics observation request"); + if ( + !Array.isArray(record.sense_addresses) + || record.sense_addresses.length === 0 + || record.sense_addresses.length > DYNAMICS_LIMITS.sense_grants + ) { + throw new Error("dynamics observation request.sense_addresses exceeds the sense grant limit"); + } + const senseAddresses = record.sense_addresses.map((address, index) => + addressString(address, `dynamics observation request.sense_addresses[${index}]`)); + if (new Set(senseAddresses).size !== senseAddresses.length) { + throw new Error("dynamics observation request.sense_addresses must be unique"); + } + return { + observer: addressString(record.observer, "dynamics observation request.observer"), + principal_id: nonEmptyString(record.principal_id, "dynamics observation request.principal_id"), + sense_addresses: [...senseAddresses].sort(codePointCompare) + }; +}; + +const parseObservationChannel = (value: unknown, path: string, granted: ReadonlySet): DynamicsObservationChannel => { + if (!isRecord(value)) throw new Error(`${path} must be an object`); + assertOnlyKeys(value, ["components", "frame", "sense_address", "subject_address", "unit"], path); + const senseAddress = addressString(value.sense_address, `${path}.sense_address`); + if (!granted.has(senseAddress)) throw new Error(`${path} returned an ungranted sense address`); + const raw = cloneDynamicsJsonObject(value.components, `${path}.components`); + const entries = Object.entries(raw).sort(([left], [right]) => codePointCompare(left, right)); + if (entries.length === 0 || entries.length > DYNAMICS_LIMITS.observation_components_per_channel) { + throw new Error(`${path}.components exceeds the component limit`); + } + const components: Record = {}; + for (const [key, component] of entries) { + const parsedKey = namedString(key, `${path}.components key`); + if (typeof component !== "number" || !Number.isFinite(component)) { + throw new Error(`${path}.components.${key} must be a named finite number`); + } + components[parsedKey] = component; + } + return { + components, + ...(value.frame === undefined ? {} : { frame: addressString(value.frame, `${path}.frame`) }), + sense_address: senseAddress, + subject_address: addressString(value.subject_address, `${path}.subject_address`), + ...(value.unit === undefined ? {} : { unit: namedString(value.unit, `${path}.unit`) }) + }; +}; + +export const parseDynamicsObservation = ( + value: unknown, + request: DynamicsObservationRequest, + tick: number +): DynamicsObservation => { + const record = cloneDynamicsJsonObject(value, "dynamics provider observation"); + assertOnlyKeys(record, ["channels"], "dynamics provider observation"); + if (!Array.isArray(record.channels) || record.channels.length > DYNAMICS_LIMITS.observation_channels) { + throw new Error("dynamics observation.channels exceeds the channel limit"); + } + const granted = new Set(request.sense_addresses); + const channels = record.channels.map((channel, index) => + parseObservationChannel(channel, `dynamics observation.channels[${index}]`, granted) + ).sort((left, right) => codePointCompare( + [left.sense_address, left.subject_address, left.frame ?? "", left.unit ?? ""].join("\0"), + [right.sense_address, right.subject_address, right.frame ?? "", right.unit ?? ""].join("\0") + )); + const channelKeys = channels.map((channel) => + [channel.sense_address, channel.subject_address, channel.frame ?? "", channel.unit ?? ""].join("\0")); + if (new Set(channelKeys).size !== channels.length) { + throw new Error("dynamics observation channel addresses must be unique"); + } + if ( + channels.reduce((total, channel) => total + Object.keys(channel.components).length, 0) + > DYNAMICS_LIMITS.json_nodes + ) throw new Error("dynamics observation exceeds the total component limit"); + return { + channels, + observer: request.observer, + principal_id: request.principal_id, + tick, + version: DYNAMICS_OBSERVATION_VERSION + }; +}; + +const parseStringRecord = (value: unknown, path: string): Record => { + if (value === undefined) return {}; + if (!isRecord(value)) throw new Error(`${path} must be an object`); + const cloned = cloneDynamicsJsonObject(value, path); + if (Object.keys(cloned).length > DYNAMICS_LIMITS.sense_grants) { + throw new Error(`${path} exceeds the dependency entry limit`); + } + return Object.fromEntries(Object.entries(cloned).sort(([left], [right]) => codePointCompare(left, right)).map(([key, entry]) => [ + nonEmptyString(key, `${path} key`), + nonEmptyString(entry, `${path}.${key}`) + ])); +}; + +export const assertDynamicsProvider: (value: unknown) => asserts value is DynamicsProvider = (value) => { + if (!isRecord(value)) throw new Error("createDynamicsProvider must return an object"); + if (value.api_version !== DYNAMICS_PROVIDER_API_VERSION) { + throw new Error(`dynamics provider.api_version must be ${DYNAMICS_PROVIDER_API_VERSION}`); + } + namedString(value.id, "dynamics provider.id"); + parseDynamicsIntegration(value.integration); + nonEmptyString(value.version, "dynamics provider.version"); + nonEmptyString(value.state_schema_version, "dynamics provider.state_schema_version"); + parseStringRecord(value.dependencies, "dynamics provider.dependencies"); + for (const method of ["initialize", "observe", "restore", "snapshot", "step"] as const) { + if (typeof value[method] !== "function") throw new Error(`dynamics provider must implement ${method}()`); + if (value[method].constructor.name === "AsyncFunction") { + throw new Error(`dynamics provider ${method}() must be synchronous`); + } + } + // Optional: absence is a supported provider shape (no motion recorded), so + // only a present-but-wrong `spatial` is an error. + if (value.spatial !== undefined) { + if (typeof value.spatial !== "function") { + throw new Error("dynamics provider spatial() must be a function"); + } + if (value.spatial.constructor.name === "AsyncFunction") { + throw new Error("dynamics provider spatial() must be synchronous"); + } + } +}; + +export const parseDynamicsIntegration = (value: unknown): DynamicsJsonObject => + cloneDynamicsJsonObject(value === undefined ? {} : value, "dynamics provider.integration"); + +export const parseDynamicsProvenance = (value: unknown): DynamicsProvenance => { + const record = cloneDynamicsJsonObject(value, "dynamics provenance"); + assertOnlyKeys(record, ["api_version", "config_sha256", "module", "module_sha256", "node_version", "numeric_model", "provider_dependencies", "provider_id", "provider_version", "state_schema_version"], "dynamics provenance"); + if (record.api_version !== DYNAMICS_PROVIDER_API_VERSION || record.numeric_model !== "ieee754-binary64") { + throw new Error("dynamics provenance contract identity is invalid"); + } + return { + api_version: DYNAMICS_PROVIDER_API_VERSION, + config_sha256: sha256String(record.config_sha256, "dynamics provenance.config_sha256"), + module: nonEmptyString(record.module, "dynamics provenance.module"), + module_sha256: sha256String(record.module_sha256, "dynamics provenance.module_sha256"), + node_version: nonEmptyString(record.node_version, "dynamics provenance.node_version"), + numeric_model: "ieee754-binary64", + provider_dependencies: parseStringRecord(record.provider_dependencies, "dynamics provenance.provider_dependencies"), + provider_id: namedString(record.provider_id, "dynamics provenance.provider_id"), + provider_version: nonEmptyString(record.provider_version, "dynamics provenance.provider_version"), + state_schema_version: nonEmptyString(record.state_schema_version, "dynamics provenance.state_schema_version") + }; +}; + +export const providerDependencies = (provider: DynamicsProvider): Record => + parseStringRecord(provider.dependencies, "dynamics provider.dependencies"); diff --git a/src/index.ts b/src/index.ts index 52483ac..df6a053 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,11 @@ export * from "./schema/index.js"; export * from "./kernel/index.js"; export * from "./ledger/index.js"; -export * from "./moltnet/index.js"; +export * from "./moltnet/root.js"; export * from "./report/index.js"; export * from "./runtime/trace.js"; export * from "./coverage/index.js"; +export { dynamicsRunStagingPrefix } from "./run/dynamics-run-artifacts.js"; +export * from "./dynamics/root.js"; +export * from "./world/index.js"; +export * from "./world-artifact/index.js"; diff --git a/src/ledger/AGENTS.md b/src/ledger/AGENTS.md index 3a609eb..cb111fc 100644 --- a/src/ledger/AGENTS.md +++ b/src/ledger/AGENTS.md @@ -11,5 +11,7 @@ This folder holds deterministic ledger primitives with no process or network I/O - `validation.ts` validates canonical ledger JSONL against that envelope shape and generalizes seq contiguity to per-(run_id, stream_id) groups (1-based, gapless within a stream). - `markers.ts` scans ledger events for marker aliases and computes simple marker outcomes. +- `principal.ts` owns the envelope principal grammar. That grammar is a cross-repository contract + and MUST NOT be widened locally; a simfile controller belongs in the `system:` trust class. Helpers are pure and intentionally small for isolated unit testing. diff --git a/src/ledger/index.ts b/src/ledger/index.ts index e4e6bbb..9ec3db6 100644 --- a/src/ledger/index.ts +++ b/src/ledger/index.ts @@ -1,3 +1,4 @@ export * from "./markers.js"; +export * from "./principal.js"; export * from "./stable.js"; export * from "./validation.js"; diff --git a/src/ledger/principal.test.ts b/src/ledger/principal.test.ts new file mode 100644 index 0000000..3e0805a --- /dev/null +++ b/src/ledger/principal.test.ts @@ -0,0 +1,98 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + readControllerPrincipalId, + resolveCausalPrincipal, + SIMFILE_CONTROLLER_PRINCIPAL_PREFIX, + SIMFILE_PRINCIPAL_GRAMMAR +} from "./principal.js"; + +const controllerPrincipal = (controllerId: string): string => + resolveCausalPrincipal({ + origin: "controller", + principalId: `controller:${controllerId}` + }); + +test("keeps distinct controller identities distinguishable and decodable", () => { + const one = controllerPrincipal("one"); + const two = controllerPrincipal("two"); + + assert.notEqual(one, two); + assert.equal(readControllerPrincipalId(one), "one"); + assert.equal(readControllerPrincipalId(two), "two"); +}); + +test("round-trips hostile controller ids verbatim", () => { + const controllerIds = [ + "contains.dot", + "contains:colon", + "controller:looking", + "λ雪", + "\ud800" + ]; + + for (const controllerId of controllerIds) { + const principal = controllerPrincipal(controllerId); + assert.match(principal, SIMFILE_PRINCIPAL_GRAMMAR); + assert.equal(readControllerPrincipalId(principal), controllerId); + } + assert.equal( + resolveCausalPrincipal({ origin: "controller", principalId: "x" }), + controllerPrincipal("x") + ); + assert.equal(readControllerPrincipalId("system:another"), undefined); +}); + +test("maps every origin and preserves grammatical principals where required", () => { + const cases: readonly Readonly<{ + expected: string; + input: Parameters[0]; + }>[] = [ + { + input: { origin: "agentic", principalId: "nora" }, + expected: "agent:nora" + }, + { + input: { origin: "agentic", principalId: "operator:nora" }, + expected: "operator:nora" + }, + { + input: { origin: "controller", principalId: "controller:one" }, + expected: "system:simfile.controller.one" + }, + { + input: { origin: "external", principalId: "outside" }, + expected: "system:simfile.external.outside" + }, + { + input: { origin: "external", principalId: "system:upstream" }, + expected: "system:upstream" + }, + { + input: { origin: "replay", principalId: "recording" }, + expected: "system:simfile.replay.recording" + }, + { + input: { origin: "replay", principalId: "agent:nora" }, + expected: "agent:nora" + } + ]; + + for (const { expected, input } of cases) { + const principal = resolveCausalPrincipal(input); + assert.equal(principal, expected); + assert.match(principal, SIMFILE_PRINCIPAL_GRAMMAR); + } + + const externalBare = resolveCausalPrincipal({ + origin: "external", + principalId: "outside" + }); + const replayBare = resolveCausalPrincipal({ + origin: "replay", + principalId: "recording" + }); + assert.equal(externalBare.startsWith(SIMFILE_CONTROLLER_PRINCIPAL_PREFIX), false); + assert.equal(replayBare.startsWith(SIMFILE_CONTROLLER_PRINCIPAL_PREFIX), false); +}); diff --git a/src/ledger/principal.ts b/src/ledger/principal.ts new file mode 100644 index 0000000..45a50f2 --- /dev/null +++ b/src/ledger/principal.ts @@ -0,0 +1,96 @@ +/* + * Simfile causal-envelope principal decision record (D1-D4). + * + * D1. The wire grammar remains closed at `^(?:agent|operator|system):.+`. + * These prefixes are trust classes, not producer names, so adding a + * `controller:` kind would widen a cross-repository contract for an identity + * that already belongs to the trusted `system:` class. Producer identity + * remains in `emitter.system`. + * + * D2. A simfile controller is encoded as + * `system:simfile.controller.`. The controller id is appended + * verbatim, rather than hex-, percent-, or otherwise escaped, because string + * concatenation and a fixed prefix-strip are injective over JavaScript UTF-16 + * code-unit sequences, including lone surrogates, which JSON stringify/parse + * also preserves. This keeps the reconciled ledger human-readable while + * making distinct controller identities recoverable from the envelope alone. + * Stele must not invent edges and this design means it never has to. + * + * D3. Origin determines the namespace. Agentic attempts use the agent trust + * class when their principal is bare; controllers always use the recoverable + * simfile controller namespace; external and replay attempts use their own + * simfile namespaces only when bare. External and replay must preserve an + * already-grammatical principal verbatim so replayed envelopes retain the + * principal identity of the original event. + * + * D4. Cause ids have the form `:`. Foreign namespaces are + * legal. Bare ids are nonconforming, but validation must never discard the + * carrying event merely because a cause id is bare. Principal resolution does + * not alter that independent cause-id rule. + */ + +import type { DynamicsActionAttempt } from "../dynamics/types.js"; + +export const SIMFILE_PRINCIPAL_GRAMMAR = /^(?:agent|operator|system):.+/u; + +export const SIMFILE_CONTROLLER_PRINCIPAL_PREFIX = + "system:simfile.controller."; + +const isGrammaticalPrincipal = (principal: string): boolean => + SIMFILE_PRINCIPAL_GRAMMAR.test(principal); + +/* Total strip: non-prefixed input is used whole, so both forms denote one id. */ +const stripLeadingControllerKind = (principalId: string): string => + principalId.startsWith("controller:") + ? principalId.slice("controller:".length) + : principalId; + +export const resolveCausalPrincipal = (input: Readonly<{ + origin: DynamicsActionAttempt["origin"]; + principalId: string; +}>): string => { + const { origin, principalId } = input; + switch (origin) { + case "agentic": + return isGrammaticalPrincipal(principalId) + ? principalId + : `agent:${principalId}`; + case "controller": + /* + * There is deliberately no grammatical-passthrough branch here. The + * controller lane (`src/world/controllerAuthority.ts:125`) always mints + * `controller:`, and routing it through the + * `system:simfile.controller.` prefix makes the identity recoverable by + * a fixed prefix-strip. This strip is total: a value that does not start + * with `controller:` is used whole, so `controller:x` and `x` denote the + * same controller. + */ + return `${SIMFILE_CONTROLLER_PRINCIPAL_PREFIX}${stripLeadingControllerKind(principalId)}`; + case "external": + /* + * External and replay preserve an already-grammatical principal + * verbatim. This is load-bearing for replay: a replayed `agent:nora` + * must remain `agent:nora`, or its ledger would not match the original. + */ + return isGrammaticalPrincipal(principalId) + ? principalId + : `system:simfile.external.${principalId}`; + case "replay": + /* + * Replay likewise preserves an already-grammatical principal verbatim; + * a replayed `agent:nora` must remain `agent:nora` so the replay ledger + * matches the original it replays. + */ + return isGrammaticalPrincipal(principalId) + ? principalId + : `system:simfile.replay.${principalId}`; + } + const exhaustiveOrigin: never = origin; + return exhaustiveOrigin; +}; + +export const readControllerPrincipalId = ( + principal: string +): string | undefined => principal.startsWith(SIMFILE_CONTROLLER_PRINCIPAL_PREFIX) + ? principal.slice(SIMFILE_CONTROLLER_PRINCIPAL_PREFIX.length) + : undefined; diff --git a/src/ledger/validation.test.ts b/src/ledger/validation.test.ts index 5673132..81b8a39 100644 --- a/src/ledger/validation.test.ts +++ b/src/ledger/validation.test.ts @@ -2,7 +2,11 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import { createCanonicalEventEnvelope, stableStringify, type LedgerEventEnvelopeInput } from "./stable.js"; -import { parseCanonicalLedgerJsonl, validateCanonicalLedgerEvents } from "./validation.js"; +import { + createCanonicalLedgerEventValidator, + parseCanonicalLedgerJsonl, + validateCanonicalLedgerEvents +} from "./validation.js"; const canonicalEvent = (seq: number, overrides: Partial = {}) => createCanonicalEventEnvelope({ @@ -66,6 +70,40 @@ describe("validateCanonicalLedgerEvents", () => { /mismatched run_id/u ); }); + + it("validates contiguous causal references incrementally", () => { + const validator = createCanonicalLedgerEventValidator({ + runId: "run-ledger", + streamId: "world" + }); + validator.validate(canonicalEvent(1)); + validator.validate(canonicalEvent(2, { + causeEventIds: ["simfile:run-ledger:1"] + })); + assert.equal(validator.count, 2); + assert.throws( + () => validator.validate(canonicalEvent(3, { + causeEventIds: ["simfile:run-ledger:4"] + })), + /unknown or non-prior cause_event_id/u + ); + assert.equal(validator.count, 2); + }); + + it("leaves cause ids outside this run's own id space untouched", () => { + // The identifier grammar for a cause id belongs to B169, not to retention: + // the incremental validator may only reject a *self* reference that is not + // prior. Externally supplied causes the runtime preserves verbatim, and ids + // from another run, stay exactly as legal as they are without it. + const validator = createCanonicalLedgerEventValidator({ + runId: "run-ledger", + streamId: "world" + }); + validator.validate(canonicalEvent(1, { + causeEventIds: ["driver:turn:7", "simfile:other-run:99", "simfile:run-ledger:not-a-seq"] + })); + assert.equal(validator.count, 1); + }); }); describe("parseCanonicalLedgerJsonl", () => { diff --git a/src/ledger/validation.ts b/src/ledger/validation.ts index 6544c25..3826882 100644 --- a/src/ledger/validation.ts +++ b/src/ledger/validation.ts @@ -14,6 +14,11 @@ export interface CanonicalLedgerValidationOptions { streamId?: string; } +export interface CanonicalLedgerEventValidator { + readonly count: number; + validate(event: unknown): LedgerEventEnvelope; +} + const provenanceValues = new Set(["mechanical", "agentic", "external"]); const isRecord = (value: unknown): value is Record => @@ -61,24 +66,21 @@ const requireEmitter = (event: Record, index: number): CausalEm return { system: SIMFILE_EMITTER_SYSTEM, stream_id: streamId, seq }; }; -/** - * Validates and normalizes a stream of simfile causal ledger envelopes. - * - * Contiguity is generalized to (run_id, stream_id): every emitter.seq must be - * 1-based and contiguous within its own (run_id, stream_id) group, even if - * multiple streams are interleaved across the input array. - */ -export const validateCanonicalLedgerEvents = ( - events: readonly unknown[], +/** Stateful validation for streaming writers that cannot retain the whole ledger. */ +export const createCanonicalLedgerEventValidator = ( options: CanonicalLedgerValidationOptions = {} -): LedgerEventEnvelope[] => { +): CanonicalLedgerEventValidator => { let runId = options.runId; + let count = 0; + let maximumPriorSequence = 0; const nextSeqByStream = new Map(); - - return events.map((event, index) => { - if (!isRecord(event)) { - throw new Error(`ledger event ${index} is not an object`); - } + return { + get count() { return count; }, + validate: (event): LedgerEventEnvelope => { + const index = count; + if (!isRecord(event)) { + throw new Error(`ledger event ${index} is not an object`); + } const version = event.version; if (version !== CAUSAL_ENVELOPE_VERSION) { @@ -101,8 +103,6 @@ export const validateCanonicalLedgerEvents = ( if (emitter.seq !== expectedSeq) { throw new Error(`ledger event ${index} has non-contiguous seq for (run_id, stream_id)`); } - nextSeqByStream.set(streamKey, emitter.seq); - const eventId = requireString(event, "event_id", index); if (eventId !== createEventId(runId, emitter.seq)) { throw new Error(`ledger event ${index} has invalid event_id`); @@ -114,6 +114,25 @@ export const validateCanonicalLedgerEvents = ( throw new Error(`ledger event ${index} has invalid recorded_at`); } const causeEventIds = requireStringArray(event, "cause_event_ids", index); + /* + * Retention only needs the acyclicity guarantee that the streaming writer can + * no longer derive from a fully retained ledger: a cause that names an event + * of *this* run must name one already emitted. What else may appear in + * cause_event_ids is the identifier-grammar question owned by B169, so + * foreign namespaces (externally supplied causes the runtime preserves + * verbatim) and self-namespace ids we cannot read as a sequence are left + * exactly as legal as they are without this validator. + */ + const selfPrefix = `${SIMFILE_EMITTER_SYSTEM}:${runId}:`; + for (const causeEventId of causeEventIds) { + if (!causeEventId.startsWith(selfPrefix)) continue; + const causeSequenceText = causeEventId.slice(selfPrefix.length); + if (!/^[1-9][0-9]*$/u.test(causeSequenceText)) continue; + const causeSequence = Number(causeSequenceText); + if (!Number.isSafeInteger(causeSequence) || causeSequence > maximumPriorSequence) { + throw new Error(`ledger event ${index} has unknown or non-prior cause_event_id`); + } + } const simTime = event.sim_time; if (typeof simTime !== "number" || !Number.isFinite(simTime)) { @@ -125,7 +144,7 @@ export const validateCanonicalLedgerEvents = ( throw new Error(`ledger event ${index} has invalid provenance`); } - return { + const normalized: LedgerEventEnvelope = { version: CAUSAL_ENVELOPE_VERSION, run_id: runId, event_id: eventId, @@ -141,7 +160,24 @@ export const validateCanonicalLedgerEvents = ( scope: requireString(event, "scope", index), payload: requirePayload(event, index) }; - }); + nextSeqByStream.set(streamKey, emitter.seq); + count += 1; + maximumPriorSequence = Math.max(maximumPriorSequence, emitter.seq); + return normalized; + } + }; +}; + +/** + * Validates and normalizes a complete stream of simfile causal envelopes. + * Contiguity is tracked independently for each (run_id, stream_id) pair. + */ +export const validateCanonicalLedgerEvents = ( + events: readonly unknown[], + options: CanonicalLedgerValidationOptions = {} +): LedgerEventEnvelope[] => { + const validator = createCanonicalLedgerEventValidator(options); + return events.map((event) => validator.validate(event)); }; export const parseCanonicalLedgerJsonl = ( diff --git a/src/moltnet/AGENTS.md b/src/moltnet/AGENTS.md index f03d018..36deba2 100644 --- a/src/moltnet/AGENTS.md +++ b/src/moltnet/AGENTS.md @@ -3,7 +3,7 @@ This folder contains Simfile-to-Moltnet bridge helpers for posting mechanical world events to Moltnet participants. -- `world-participant.ts` maps `world.message`, `world.dm`, and `wake.recommended` +- `world-participant.ts` maps `world.message` and `world.dm` events into Moltnet `/v1/messages` requests. - `transcript-export.ts` reads room and DM transcripts back from Moltnet and marks them as the accepted `moltnet-exported` social record. diff --git a/src/moltnet/index.ts b/src/moltnet/index.ts index 29fcbfb..b7affba 100644 --- a/src/moltnet/index.ts +++ b/src/moltnet/index.ts @@ -1,2 +1,3 @@ export * from "./world-participant.js"; export * from "./transcript-export.js"; +export type { MoltnetRoomMessage } from "./types.js"; diff --git a/src/moltnet/machine/AGENTS.md b/src/moltnet/machine/AGENTS.md new file mode 100644 index 0000000..a3f4a0e --- /dev/null +++ b/src/moltnet/machine/AGENTS.md @@ -0,0 +1,14 @@ +# Simfile Moltnet Machine Adapter + +This folder owns only the lifecycle of a long-lived `moltnet machine` +subprocess. It speaks the frozen `moltnet.machine.v1` JSONL protocol through +stdio and must not contain HTTP, bearer, URL, actor, retry, cursor, fixture, +or world-specific behavior. + +- `protocol.ts` validates the small enabled protocol surface (`send_nudge`, + `read`, and `cancel`) before values reach callers. +- `client.ts` owns child-process lifecycle, bounded in-flight requests, + backpressure, strict output handling, and cancellation forwarding. +- Tests use a fake subprocess only. A fixture may bind the generic client to + a provider-owned executable and private config without moving provider + semantics into Simfile. diff --git a/src/moltnet/machine/client.test.ts b/src/moltnet/machine/client.test.ts new file mode 100644 index 0000000..9ad32d9 --- /dev/null +++ b/src/moltnet/machine/client.test.ts @@ -0,0 +1,174 @@ +import assert from "node:assert/strict"; +import { EventEmitter } from "node:events"; +import { readFile } from "node:fs/promises"; +import { PassThrough, Writable } from "node:stream"; +import { describe, it } from "node:test"; + +import { createMoltnetMachineClient } from "./client.js"; + +const script = String.raw` +let pending = new Map(); +process.stdin.setEncoding("utf8"); +process.stdin.on("data", (data) => { + for (const line of data.trim().split("\n")) { + const request = JSON.parse(line); + if (request.operation === "cancel") { + const target = pending.get(request.cancel.target_correlation_id); + if (target) { + pending.delete(request.cancel.target_correlation_id); + process.stdout.write(JSON.stringify({version:"moltnet.machine.v1",correlation_id:target.correlation_id,operation:target.operation,error:{code:"canceled"}})+"\n"); + } + process.stdout.write(JSON.stringify({version:"moltnet.machine.v1",correlation_id:request.correlation_id,operation:"cancel",cancel:{target_correlation_id:request.cancel.target_correlation_id,state:"canceled"}})+"\n"); + } else if (request.operation === "send_nudge") { + process.stdout.write(JSON.stringify({version:"moltnet.machine.v1",correlation_id:request.correlation_id,operation:"send_nudge",send_nudge:{message_id:"message_1",event_id:"event_1",accepted:true,thread_created:false,dm_created:false}})+"\n"); + } else if (request.operation === "read") { + pending.set(request.correlation_id, request); + } + } +});`; + +const malformedScript = String.raw` +process.stdin.once("data", () => process.stdout.write('{"version":"moltnet.machine.v1","correlation_id":"send_bad","operation":"send_nudge","send_nudge":{"message_id":"message_1","event_id":"event_1","accepted":true,"thread_created":false,"dm_created":false},"unknown":true}\n')); +`; + +const request = (correlation_id: string) => ({ + version: "moltnet.machine.v1" as const, correlation_id, operation: "send_nudge" as const, + send_nudge: { delivery_id: `delivery_${correlation_id}`, target: { kind: "dm" as const, id: "peer_1" }, body: "wake" } +}); + +describe("MoltnetMachineClient", () => { + it("uses one long-lived fake machine for send and cancellation", async () => { + const client = createMoltnetMachineClient({ command: process.execPath, args: ["--input-type=module", "--eval", script] }); + const response = await client.request({ + version: "moltnet.machine.v1", correlation_id: "send_1", operation: "send_nudge", + send_nudge: { delivery_id: "delivery_1", target: { kind: "dm", id: "peer_1" }, body: "wake" } + }); + assert.equal(response.send_nudge?.message_id, "message_1"); + const controller = new AbortController(); + const pending = client.request({ + version: "moltnet.machine.v1", correlation_id: "read_1", operation: "read", + read: { target: { kind: "dm", id: "peer_1" }, limit: 1 } + }, controller.signal); + controller.abort(); + await assert.rejects(pending, /aborted/u); + const stillUsable = await client.request({ + version: "moltnet.machine.v1", correlation_id: "send_2", operation: "send_nudge", + send_nudge: { delivery_id: "delivery_2", target: { kind: "dm", id: "peer_1" }, body: "wake" } + }); + assert.equal(stillUsable.send_nudge?.message_id, "message_1"); + await client.close(); + }); + + it("fails closed on a malformed machine frame", async () => { + const client = createMoltnetMachineClient({ command: process.execPath, args: ["--input-type=module", "--eval", malformedScript] }); + await assert.rejects(client.request({ + version: "moltnet.machine.v1", correlation_id: "send_bad", operation: "send_nudge", + send_nudge: { delivery_id: "delivery_bad", target: { kind: "dm", id: "peer_1" }, body: "wake" } + }), /machine|response/u); + await client.close(); + }); + + it("drains bounded stdin writes and fails every pending request on a writer error", async () => { + const stdout = new PassThrough(); + const received: string[] = []; + const stdin = new Writable({ highWaterMark: 1, write(chunk, _encoding, done) { + received.push(chunk.toString("utf8")); + const frame = JSON.parse(chunk.toString("utf8")) as { correlation_id: string }; + setTimeout(() => { + stdout.write(JSON.stringify({ version: "moltnet.machine.v1", correlation_id: frame.correlation_id, operation: "send_nudge", send_nudge: { message_id: "message_1", event_id: "event_1", accepted: true, thread_created: false, dm_created: false } }) + "\n"); + done(); + }, 1); + }}); + const child = Object.assign(new EventEmitter(), { stdin, stdout, kill: () => true }); + const client = createMoltnetMachineClient({ command: "fake", spawnChild: () => child as never }); + const responses = await Promise.all(Array.from({ length: 16 }, (_, index) => client.request(request(`drain_${index}`)))); + assert.equal(responses.length, 16); + assert.equal(received.length, 16); + await client.close(); + + const failedStdout = new PassThrough(); + const failedStdin = new Writable({ write(_chunk, _encoding, done) { done(new Error("writer failed")); } }); + const failedChild = Object.assign(new EventEmitter(), { stdin: failedStdin, stdout: failedStdout, kill: () => true }); + const failed = createMoltnetMachineClient({ command: "fake", spawnChild: () => failedChild as never }); + await assert.rejects(failed.request(request("writer_error")), /write|machine/u); + await failed.close(); + }); + + it("rejects an oversized unterminated stdout tail after a parsed line", async () => { + const tailScript = String.raw` +process.stdin.once("data", (chunk) => { + const request = JSON.parse(chunk.toString("utf8")); + process.stdout.write(JSON.stringify({version:"moltnet.machine.v1",correlation_id:request.correlation_id,operation:"send_nudge",send_nudge:{message_id:"message_1",event_id:"event_1",accepted:true,thread_created:false,dm_created:false}})+"\n" + "x".repeat(16385)); +});`; + const client = createMoltnetMachineClient({ command: process.execPath, args: ["--input-type=module", "--eval", tailScript] }); + await client.request(request("tail_1")); + await new Promise((resolve) => setTimeout(resolve, 20)); + await assert.rejects(client.request(request("tail_2")), /closed|machine/u); + await client.close(); + }); + + it("forwards exactly one cancel for each active request before EOF settlement", async () => { + const stdout = new PassThrough(); + const frames: Array<{ operation: string; cancel?: { target_correlation_id: string } }> = []; + const stdin = new Writable({ write(chunk, _encoding, done) { + frames.push(JSON.parse(chunk.toString("utf8")) as typeof frames[number]); + done(); + }}); + const child = Object.assign(new EventEmitter(), { stdin, stdout, kill: () => true }); + const client = createMoltnetMachineClient({ command: "fake", spawnChild: () => child as never }); + const active = client.request({ + version: "moltnet.machine.v1", correlation_id: "read_eof", operation: "read", + read: { target: { kind: "room", id: "room_1" }, limit: 1 } + }); + stdout.end(); + await assert.rejects(active, /EOF/u); + assert.deepEqual(frames.map((frame) => frame.operation), ["read", "cancel"]); + assert.equal(frames[1]?.cancel?.target_correlation_id, "read_eof"); + await client.close(); + }); + + it("drains EOF cancellation through a forced blocked writer without hanging", async () => { + const stdout = new PassThrough(); + const frames: Array<{ operation: string; cancel?: { target_correlation_id: string } }> = []; + let releaseInitial: (() => void) | undefined; + const stdin = new Writable({ highWaterMark: 1, write(chunk, _encoding, done) { + const frame = JSON.parse(chunk.toString("utf8")) as typeof frames[number]; + frames.push(frame); + if (frame.operation === "read") releaseInitial = done; + else setImmediate(done); + }}); + const child = Object.assign(new EventEmitter(), { stdin, stdout, kill: () => true }); + const client = createMoltnetMachineClient({ command: "fake", spawnChild: () => child as never }); + const active = client.request({ + version: "moltnet.machine.v1", correlation_id: "read_blocked_eof", operation: "read", + read: { target: { kind: "room", id: "room_1" }, limit: 1 } + }); + stdout.end(); + assert.deepEqual(frames.map((frame) => frame.operation), ["read"]); + releaseInitial?.(); + let timeout: NodeJS.Timeout | undefined; + try { + await Promise.race([ + assert.rejects(active, /EOF/u), + new Promise((_resolve, reject) => { timeout = setTimeout(() => reject(new Error("EOF drain hung")), 500); }) + ]); + } finally { + if (timeout) clearTimeout(timeout); + } + assert.deepEqual(frames.map((frame) => frame.operation), ["read", "cancel"]); + assert.equal(frames[1]?.cancel?.target_correlation_id, "read_blocked_eof"); + assert.equal(stdin.writableEnded, true); + await assert.rejects(client.request(request("after_eof")), /closed/u); + await client.close(); + }); + + it("contains no direct provider transport implementation", async () => { + const sources = await Promise.all(["client.ts", "protocol.ts", "types.ts"].map(async (name) => + readFile(new URL(`./${name}`, import.meta.url), "utf8"))); + for (const source of sources) { + for (const forbidden of ["fetch(", "EventSource", "WebSocket", "Authorization", "Bearer ", "http://", "https://"]) { + assert.equal(source.includes(forbidden), false, forbidden); + } + } + }); +}); diff --git a/src/moltnet/machine/client.ts b/src/moltnet/machine/client.ts new file mode 100644 index 0000000..053bddf --- /dev/null +++ b/src/moltnet/machine/client.ts @@ -0,0 +1,195 @@ +import { spawn, type ChildProcessByStdio } from "node:child_process"; +import type { Readable, Writable } from "node:stream"; + +import { decodeMoltnetMachineTerminal, encodeMoltnetMachineRequest } from "./protocol.js"; +import { + MOLTNET_MACHINE_MAX_ACTIVE_REQUESTS, + MOLTNET_MACHINE_MAX_LINE_BYTES, + MoltnetMachineError, + type MoltnetMachineRequest, + type MoltnetMachineTerminal +} from "./types.js"; + +export interface MoltnetMachineClientOptions { + readonly command: string; + readonly args?: readonly string[]; + readonly env?: NodeJS.ProcessEnv; + readonly spawnChild?: (command: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv }) => MachineChild; +} + +type MachineChild = ChildProcessByStdio; + +interface PendingRequest { + readonly operation: MoltnetMachineRequest["operation"]; + readonly reject: (reason: Error) => void; + readonly resolve: (value: MoltnetMachineTerminal) => void; + readonly detachAbort: () => void; + canceled: boolean; + settled: boolean; +} + +interface QueuedWrite { + readonly line: string; +} + +export interface MoltnetMachineClient { + request(request: MoltnetMachineRequest, signal?: AbortSignal): Promise; + close(): Promise; +} + +const childSpawner = (command: string, args: readonly string[], options: { env?: NodeJS.ProcessEnv }): MachineChild => + spawn(command, args, { env: options.env, stdio: ["pipe", "pipe", "ignore"] }); + +export const createMoltnetMachineClient = (options: MoltnetMachineClientOptions): MoltnetMachineClient => { + const child = (options.spawnChild ?? childSpawner)(options.command, options.args ?? ["machine"], { env: options.env }); + const pending = new Map(); + const cancelCorrelations = new Set(); + const writes: QueuedWrite[] = []; + const maxQueuedWrites = MOLTNET_MACHINE_MAX_ACTIVE_REQUESTS * 2; + const maxQueuedBytes = maxQueuedWrites * (MOLTNET_MACHINE_MAX_LINE_BYTES + 1); + let closed = false; + let eofPending = false; + let eofReason: Error | undefined; + let eofDeadline: NodeJS.Timeout | undefined; + let sequence = 0; + let writeBlocked = false; + let queuedBytes = 0; + let finishEof = (): void => {}; + const failWriter = (): void => fail(new MoltnetMachineError("machine write failed")); + const flushWrites = (): void => { + if (closed || writeBlocked) return; + while (writes.length > 0) { + const frame = writes.shift()!; + queuedBytes -= Buffer.byteLength(frame.line, "utf8"); + try { + if (!child.stdin.write(frame.line, (error) => { if (error) failWriter(); })) { + writeBlocked = true; + return; + } + } catch { failWriter(); return; } + } + finishEof(); + }; + const enqueueWrite = (line: string): boolean => { + const bytes = Buffer.byteLength(line, "utf8"); + if (closed || writes.length >= maxQueuedWrites || queuedBytes + bytes > maxQueuedBytes) return false; + writes.push({ line }); queuedBytes += bytes; flushWrites(); + return !closed; + }; + const fail = (reason: Error, graceful = false): void => { + if (closed) return; + closed = true; + if (eofDeadline) clearTimeout(eofDeadline); + for (const request of pending.values()) { + request.detachAbort(); + if (!request.settled) request.reject(reason); + } + pending.clear(); + writes.length = 0; + queuedBytes = 0; + cancelCorrelations.clear(); + if (graceful) { + child.stdin.end(); + const forceKill = setTimeout(() => child.kill(), 1_000); + forceKill.unref(); + return; + } + child.stdin.destroy(); child.kill(); + }; + finishEof = () => { + if (!eofReason || writeBlocked || writes.length > 0) return; + const reason = eofReason; + eofReason = undefined; + fail(reason, true); + }; + const cancel = (target: string): void => { + const pendingRequest = pending.get(target); + if (!pendingRequest || pendingRequest.canceled || closed) return; + pendingRequest.canceled = true; + const correlation = `cancel_${++sequence}_${target}`.slice(0, 128); + try { + const line = `${encodeMoltnetMachineRequest({ version: "moltnet.machine.v1", correlation_id: correlation, operation: "cancel", cancel: { target_correlation_id: target } })}\n`; + if (enqueueWrite(line)) cancelCorrelations.add(correlation); + else failWriter(); + } catch { failWriter(); } + }; + let stdout = Buffer.alloc(0); + const consumeLine = (line: string): void => { + try { + const response = decodeMoltnetMachineTerminal(line); const request = pending.get(response.correlation_id); + if (cancelCorrelations.delete(response.correlation_id)) return; + if (!request || request.operation !== response.operation) throw new MoltnetMachineError("unexpected machine response"); + pending.delete(response.correlation_id); request.detachAbort(); + if (!request.settled) request.resolve(response); + } catch (error) { fail(error instanceof Error ? error : new MoltnetMachineError("invalid machine response")); } + }; + child.stdin.on("drain", () => { writeBlocked = false; flushWrites(); }); + child.stdin.on("error", failWriter); + child.stdout.on("data", (chunk: Buffer) => { + stdout = Buffer.concat([stdout, chunk]); + if (stdout.length > MOLTNET_MACHINE_MAX_LINE_BYTES && !stdout.includes(0x0a)) return fail(new MoltnetMachineError("machine response exceeds line limit")); + for (;;) { + const end = stdout.indexOf(0x0a); if (end < 0) break; + const line = stdout.subarray(0, end); stdout = stdout.subarray(end + 1); + if (line.length > MOLTNET_MACHINE_MAX_LINE_BYTES) return fail(new MoltnetMachineError("machine response exceeds line limit")); + try { + consumeLine(new TextDecoder("utf-8", { fatal: true }).decode(line).replace(/\r$/u, "")); + } catch { fail(new MoltnetMachineError("invalid machine response encoding")); return; } + } + if (stdout.length > MOLTNET_MACHINE_MAX_LINE_BYTES) fail(new MoltnetMachineError("machine response exceeds line limit")); + }); + child.stdout.on("end", () => { + if (closed || eofPending) return; + eofPending = true; + for (const id of pending.keys()) cancel(id); + if (closed) return; + eofReason = new MoltnetMachineError("machine EOF"); + eofDeadline = setTimeout(() => { + const reason = eofReason; + eofReason = undefined; + if (reason) fail(reason); + }, 1_000); + eofDeadline.unref(); + finishEof(); + }); + child.on("error", () => fail(new MoltnetMachineError("machine subprocess failure"))); + child.on("exit", () => { if (!eofPending) fail(new MoltnetMachineError("machine subprocess exited")); }); + + return { + request(request, signal) { + if (closed || eofPending) return Promise.reject(new MoltnetMachineError("machine client is closed")); + if (pending.has(request.correlation_id) || pending.size >= MOLTNET_MACHINE_MAX_ACTIVE_REQUESTS) return Promise.reject(new MoltnetMachineError("machine request capacity or duplicate")); + return new Promise((resolve, reject) => { + let abort = (): void => {}; + const item: PendingRequest = { + operation: request.operation, resolve, reject, canceled: false, settled: false, + detachAbort: () => signal?.removeEventListener("abort", abort), + }; + pending.set(request.correlation_id, item); + abort = () => { + if (item.settled) return; + item.settled = true; cancel(request.correlation_id); + reject(new MoltnetMachineError("machine request aborted")); + }; + if (signal?.aborted) { + pending.delete(request.correlation_id); + item.settled = true; + reject(new MoltnetMachineError("machine request aborted")); + return; + } + signal?.addEventListener("abort", abort, { once: true }); + try { + const line = `${encodeMoltnetMachineRequest(request)}\n`; + if (!enqueueWrite(line)) throw new MoltnetMachineError("machine write queue capacity"); + } catch (error) { + pending.delete(request.correlation_id); item.detachAbort(); + reject(error instanceof Error ? error : new MoltnetMachineError("machine write failed")); + } + }); + }, + async close() { + for (const id of pending.keys()) cancel(id); + fail(new MoltnetMachineError("machine client closed"), true); + } + }; +}; diff --git a/src/moltnet/machine/index.ts b/src/moltnet/machine/index.ts new file mode 100644 index 0000000..1ea6734 --- /dev/null +++ b/src/moltnet/machine/index.ts @@ -0,0 +1,3 @@ +export * from "./client.js"; +export * from "./protocol.js"; +export * from "./types.js"; diff --git a/src/moltnet/machine/protocol.test.ts b/src/moltnet/machine/protocol.test.ts new file mode 100644 index 0000000..160e44f --- /dev/null +++ b/src/moltnet/machine/protocol.test.ts @@ -0,0 +1,126 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { describe, it } from "node:test"; + +import { decodeMoltnetMachineTerminal, encodeMoltnetMachineRequest } from "./protocol.js"; +import { MOLTNET_MACHINE_CONTRACT_SHA256, MoltnetMachineError } from "./types.js"; + +const send = (extra = "") => `{"version":"moltnet.machine.v1","correlation_id":"send_1","operation":"send_nudge","send_nudge":{"message_id":"message_1","event_id":"event_1","accepted":true,"thread_created":false,"dm_created":false${extra}}}`; +const sha256 = (value: string): string => createHash("sha256").update(value, "utf8").digest("hex"); + +const GOLDEN_HASHES = Object.freeze({ + contract: "1ed6bdc3a9600fd5fc55052d4ba20d1c3d13a7e37daf0465b4543ff5bc5cc64d", + send_nudge_request: "b91e932007623375878d5c229521c9f6d9f069d843ff505280b9f4132efaf6b4", + read_request: "995ae29cae52959ec3cd9dc23af5f5372fdde5a191007e9e01aee7d3f46a98d0", + cancel_request: "243b527b184b44ee9b13a1391aa03067e8ca0255e0dc8fa03d1fce706e535ae4", + send_nudge_success: "061143f0a26c5dc2ec3f3d5a781dfa08e754ec9760ec1c40a3aa11f42fa933ec", + read_success_empty: "b4facf0071da6b1b695aee379477c22356cf53a6ee06f53dabb6deb36f368998", + read_success_nonempty_with_after: "1cf6b11be03b57dcfb921228fdf3f7a6bc9acb0cef427bd45afa227d66d4d9aa", + read_success_nonempty_with_before: "2c09baa66b91d07639e78863a3a8d7853cd3e53480973d35d00ebed8ab40af61", + cancel_success: "0a125231cbe05fc8d7fda1d5fa55cb98665fe5d032e15ece34ecee3d06886c28", + cancel_already_final: "0a204b6226408eb4a5b57df2caeb8b0728b41fbc96e0b6ad1d71bd9ac703f447", + cancel_not_found: "ed7433cd8c925257bd66649c6a649d0e55d5534db10482575a07fd8fddc4efe1", +}); + +describe("moltnet machine protocol", () => { + it("encodes only the frozen enabled request shape", () => { + assert.equal(encodeMoltnetMachineRequest({ + version: "moltnet.machine.v1", correlation_id: "read_1", operation: "read", + read: { target: { kind: "room", id: "room_1" }, limit: 1 } + }), "{\"version\":\"moltnet.machine.v1\",\"correlation_id\":\"read_1\",\"operation\":\"read\",\"read\":{\"target\":{\"kind\":\"room\",\"id\":\"room_1\"},\"limit\":1}}"); + }); + + it("enforces the provider's UTF-8 body limit", () => { + const request = { + version: "moltnet.machine.v1" as const, correlation_id: "send_1", operation: "send_nudge" as const, + send_nudge: { delivery_id: "delivery_1", target: { kind: "dm" as const, id: "peer_1" }, body: "é".repeat(1_025) } + }; + assert.throws(() => encodeMoltnetMachineRequest(request), MoltnetMachineError); + assert.throws(() => encodeMoltnetMachineRequest({ + ...request, send_nudge: { ...request.send_nudge, body: " \t " } + }), MoltnetMachineError); + }); + + it("rejects version drift, excess payloads, and duplicate delivery causes", () => { + const valid = { + version: "moltnet.machine.v1" as const, correlation_id: "send_1", operation: "send_nudge" as const, + send_nudge: { delivery_id: "delivery_1", target: { kind: "dm" as const, id: "peer_1" }, body: "wake" } + }; + assert.throws(() => encodeMoltnetMachineRequest({ ...valid, version: "moltnet.machine.v2" } as never), MoltnetMachineError); + assert.throws(() => encodeMoltnetMachineRequest({ ...valid, read: { target: { kind: "dm", id: "peer_1" }, limit: 1 } } as never), MoltnetMachineError); + assert.throws(() => encodeMoltnetMachineRequest({ ...valid, send_nudge: { ...valid.send_nudge, cause_event_ids: ["cause_1", "cause_1"] } }), MoltnetMachineError); + }); + + it("pins the reviewed provider contract and enabled golden vectors", () => { + assert.equal(MOLTNET_MACHINE_CONTRACT_SHA256, GOLDEN_HASHES.contract); + const requests = [ + ["send_nudge_request", { version: "moltnet.machine.v1", correlation_id: "corr_send_1", operation: "send_nudge", send_nudge: { delivery_id: "delivery_1", target: { kind: "room", id: "room_1" }, body: "wake for nudge", origin_message_id: "origin_1", cause_event_ids: ["ev_1", "ev_2"] } }], + ["read_request", { version: "moltnet.machine.v1", correlation_id: "corr_read_1", operation: "read", read: { target: { kind: "room", id: "room_1" }, limit: 20, after: "msg_1" } }], + ["cancel_request", { version: "moltnet.machine.v1", correlation_id: "corr_can_1", operation: "cancel", cancel: { target_correlation_id: "corr_read_1" } }], + ] as const; + for (const [name, request] of requests) assert.equal(sha256(encodeMoltnetMachineRequest(request)), GOLDEN_HASHES[name]); + const terminals = [ + ["send_nudge_success", `{"version":"moltnet.machine.v1","correlation_id":"corr_send_1","operation":"send_nudge","send_nudge":{"message_id":"message_1","event_id":"event_1","accepted":true,"thread_id":"thread_1","thread_created":true,"dm_created":false}}`], + ["read_success_empty", `{"version":"moltnet.machine.v1","correlation_id":"corr_read_3","operation":"read","read":{"target":{"kind":"room","id":"room_1"},"page":{"messages":null,"page":{"has_more":false}}}}`], + ["read_success_nonempty_with_after", `{"version":"moltnet.machine.v1","correlation_id":"corr_read_1","operation":"read","read":{"target":{"kind":"room","id":"room_1"},"page":{"messages":[{"id":"msg_2","network_id":"net_1","origin":{"network_id":"net_1","message_id":"msg_1"},"target":{"kind":"room","room_id":"room_1"},"from":{"type":"agent","id":"agent_1"},"parts":[{"kind":"text","text":"hello"}],"mentions":["agent_2"],"created_at":"2026-07-21T00:00:00Z"}],"page":{"has_more":true,"next_after":"msg_3"}}}}`], + ["read_success_nonempty_with_before", `{"version":"moltnet.machine.v1","correlation_id":"corr_read_2","operation":"read","read":{"target":{"kind":"room","id":"room_1"},"page":{"messages":[{"id":"msg_2","network_id":"net_1","origin":{"network_id":"net_1","message_id":"msg_1"},"target":{"kind":"room","room_id":"room_1"},"from":{"type":"agent","id":"agent_1"},"parts":[{"kind":"text","text":"hello"}],"mentions":["agent_2"],"created_at":"2026-07-21T00:00:00Z"}],"page":{"has_more":true,"next_before":"msg_1"}}}}`], + ["cancel_success", `{"version":"moltnet.machine.v1","correlation_id":"corr_can_1","operation":"cancel","cancel":{"target_correlation_id":"corr_read_1","state":"canceled"}}`], + ["cancel_already_final", `{"version":"moltnet.machine.v1","correlation_id":"corr_can_2","operation":"cancel","cancel":{"target_correlation_id":"corr_read_1","state":"already_final"}}`], + ["cancel_not_found", `{"version":"moltnet.machine.v1","correlation_id":"corr_can_3","operation":"cancel","cancel":{"target_correlation_id":"corr_read_1","state":"not_found"}}`], + ] as const; + for (const [name, line] of terminals) { + assert.equal(sha256(line), GOLDEN_HASHES[name]); + assert.doesNotThrow(() => decodeMoltnetMachineTerminal(line)); + } + }); + + for (const [name, line] of [ + ["unknown", send(',"unknown":"x"')], + ["duplicate", `{"version":"moltnet.machine.v1","correlation_id":"send_1","operation":"send_nudge","send_nudge":{"message_id":"message_1","message_id":"message_2","event_id":"event_1","accepted":true,"thread_created":false,"dm_created":false}}`], + ["version", send().replace("moltnet.machine.v1", "moltnet.machine.v2")], + ["nonterminal", `{"version":"moltnet.machine.v1","correlation_id":"sub_1","operation":"subscribe","event":{"event_id":"event_1","type":"message","payload":{}}}`], + ["oversized", `${send()}${" ".repeat(16_384)}`] + ] as const) { + it(`rejects ${name} machine output`, () => assert.throws(() => decodeMoltnetMachineTerminal(line), MoltnetMachineError)); + } + + it("accepts an exact send terminal", () => { + assert.equal(decodeMoltnetMachineTerminal(send()).send_nudge?.message_id, "message_1"); + }); + + it("rejects hostile nested read values and locks enabled error terminals", () => { + const baseline = `{"version":"moltnet.machine.v1","correlation_id":"corr_read_1","operation":"read","read":{"target":{"kind":"room","id":"room_1"},"page":{"messages":[{"id":"msg_1","network_id":"net_1","origin":{"network_id":"net_1","message_id":"origin_1"},"target":{"kind":"room","room_id":"room_1"},"from":{"type":"agent","id":"agent_1"},"parts":[{"kind":"text","text":"hello"}],"created_at":"2026-07-21T00:00:00Z"}],"page":{"has_more":false}}}}`; + for (const hostile of [ + baseline.replace('"room_id":"room_1"', '"room_id":"room_1","dm_id":"dm_1"'), + baseline.replace('"kind":"text"', '"kind":"unknown"'), + baseline.replace('"text":"hello"', '"url":"ftp://host"'), + baseline.replace('"text":"hello"', `"data":{"x":"${"x".repeat(8_193)}"}`), + baseline.replace('"id":"agent_1"', '"id":"bad member"'), + baseline.replace('"id":"agent_1"', '"id":"net_1/agent_1"'), + baseline.replace('"id":"agent_1"', '"id":"net::agent"') + ]) assert.throws(() => decodeMoltnetMachineTerminal(hostile), MoltnetMachineError); + + const errors = [ + ["invalid_request", "corr_err_1", "4e50530254a1c333e68f2f48a47fb49380a69e011c6b20bc5a4dd7542611b15a"], + ["duplicate_request", "corr_err_2", "38ae4836cbc0f504894166738af6b82fd59df9764fd76658fc917e2b4b05cbc5"], + ["not_found", "corr_err_3", "09e2fa7166c49fdc9194ae2647fc517f801aba346f95b40ba84338b6bdc12f5d"], + ["conflict", "corr_err_4", "348e213bfa2b8f16efbeb36be0b39844464db0470a86d53acd4a1e781715e24e"], + ["capacity", "corr_err_5", "e1d1a2a982de46da8b1101c1cee0e487008a8ef75c1e8b6a4491511836317042"], + ["transport", "corr_err_6", "937ad520f1d374230278302ed73a717d22b16b7041af834e61da26155190477a"], + ["canceled", "corr_err_7", "62037cefb5dc34f8ec7959339f4fa78d3ac166b7c27f0cbd84b5ec22b2409409"] + ] as const; + for (const [code, correlation, hash] of errors) { + const line = `{"version":"moltnet.machine.v1","correlation_id":"${correlation}","operation":"send_nudge","error":{"code":"${code}"}}`; + assert.equal(sha256(line), hash); + assert.doesNotThrow(() => decodeMoltnetMachineTerminal(line)); + } + + for (const accepted of [ + baseline.replace('"id":"agent_1"', '"id":"net_1:agent_1"'), + baseline.replace('"id":"agent_1"', `"id":"${"n".repeat(128)}:${"a".repeat(128)}"`), + baseline.replace('"id":"agent_1"', '"id":"molt://net_1/agents/agent_1"'), + baseline.replace('"id":"agent_1"', '"id":"agent_1","credential_bound":true'), + baseline.replace("2026-07-21T00:00:00Z", "2026-07-21T02:00:00+02:00") + ]) assert.doesNotThrow(() => decodeMoltnetMachineTerminal(accepted)); + }); +}); diff --git a/src/moltnet/machine/protocol.ts b/src/moltnet/machine/protocol.ts new file mode 100644 index 0000000..c47e4b9 --- /dev/null +++ b/src/moltnet/machine/protocol.ts @@ -0,0 +1,174 @@ +import { z } from "zod"; + +import { + MOLTNET_MACHINE_MAX_LINE_BYTES, + MOLTNET_MACHINE_VERSION, + MoltnetMachineError, + type MoltnetMachineRequest, + type MoltnetMachineTerminal +} from "./types.js"; + +const identifier = z.string().min(1).max(128).regex(/^[A-Za-z0-9][A-Za-z0-9._:-]*$/u); +const utf8AtMost = (maximum: number) => z.string().refine((value) => Buffer.byteLength(value, "utf8") <= maximum); +const scopedMemberId = z.string().refine((value) => { + if (value.trim() !== value || Buffer.byteLength(value, "utf8") === 0) return false; + const fqid = /^molt:\/\/([^/]+)\/agents\/([^/]+)$/u.exec(value); + if (fqid !== null) return identifier.safeParse(fqid[1]).success && identifier.safeParse(fqid[2]).success; + if (value.includes(":")) { + const components = value.split(":"); + const component = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/u; + return components.length === 2 && components.every((entry) => component.test(entry)); + } + return identifier.safeParse(value).success; +}); +const target = z.object({ kind: z.enum(["room", "dm"]), id: identifier }).strict(); +const send = z.object({ + delivery_id: identifier, target, body: utf8AtMost(2_048).min(1).refine((value) => value.trim().length > 0), + origin_message_id: identifier.optional(), cause_event_ids: z.array(identifier).max(32).optional() +}).strict().superRefine((value, context) => { + if (value.cause_event_ids && new Set(value.cause_event_ids).size !== value.cause_event_ids.length) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "duplicate machine cause event" }); + } +}); +const read = z.object({ + target, limit: z.number().int().min(1).max(128), before: identifier.optional(), after: identifier.optional() +}).strict().refine((value) => !(value.before && value.after), "read cursor conflict"); +const cancel = z.object({ target_correlation_id: identifier }).strict(); +const error = z.object({ code: z.enum([ + "invalid_request", "duplicate_request", "unsupported", "not_found", "conflict", "capacity", "transport", "canceled" +]) }).strict(); +const sendResult = z.object({ + message_id: identifier, event_id: identifier, accepted: z.boolean(), thread_id: identifier.optional(), + thread_created: z.boolean(), dm_id: identifier.optional(), dm_created: z.boolean() +}).strict().superRefine((value, context) => { + if ((value.thread_id !== undefined) !== value.thread_created || (value.dm_id !== undefined) !== value.dm_created) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "invalid machine creation result" }); + } +}); +const memberId = scopedMemberId; +const messageTarget = z.object({ + kind: z.enum(["room", "dm"]), room_id: identifier.optional(), thread_id: identifier.optional(), + parent_message_id: identifier.optional(), dm_id: identifier.optional(), participant_ids: z.array(memberId).max(128).optional() +}).strict().superRefine((value, context) => { + const hasRoomFields = value.room_id !== undefined || value.thread_id !== undefined || value.parent_message_id !== undefined; + if (value.kind === "room" && (value.room_id === undefined || value.dm_id !== undefined || value.participant_ids !== undefined || value.thread_id !== undefined || value.parent_message_id !== undefined)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "invalid room message target" }); + } + if (value.kind === "dm" && (value.dm_id === undefined || hasRoomFields || value.participant_ids !== undefined)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "invalid direct message target" }); + } +}); +const actor = z.object({ + type: utf8AtMost(128).min(1).refine((value) => value.trim() === value), id: memberId, + name: utf8AtMost(128).optional(), network_id: identifier.optional(), fqid: utf8AtMost(128).optional(), + credential_bound: z.boolean().optional() +}).strict(); +const knownPartKind = z.enum(["text", "url", "data", "file", "image", "audio"]); +const validPartUrl = (value: string): boolean => { + try { + const parsed = new URL(value); + return (parsed.protocol === "http:" || parsed.protocol === "https:" || parsed.protocol === "molt:") + && (parsed.protocol === "molt:" || parsed.host.length > 0); + } catch { return false; } +}; +const part = z.object({ + kind: knownPartKind, text: utf8AtMost(4_096).optional(), media_type: utf8AtMost(128).optional(), + url: utf8AtMost(2_048).optional(), filename: utf8AtMost(256).optional(), data: z.record(z.string(), z.unknown()).optional() +}).strict().superRefine((value, context) => { + if (value.url !== undefined && (value.url.trim() !== value.url || !validPartUrl(value.url))) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "invalid machine part URL" }); + } + if (value.data !== undefined && Buffer.byteLength(JSON.stringify(value.data), "utf8") > 8_192) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "machine part data exceeds byte limit" }); + } +}); +const readMessage = z.object({ + id: identifier, network_id: identifier, origin: z.object({ network_id: identifier, message_id: identifier }).strict(), + target: messageTarget, from: actor, parts: z.array(part).min(1).max(64), mentions: z.array(memberId).max(128).optional(), + created_at: z.string().datetime({ offset: true }) +}).strict(); +const readResult = z.object({ + target, page: z.object({ + messages: z.array(readMessage).max(128).nullable(), + page: z.object({ has_more: z.boolean(), next_before: identifier.optional(), next_after: identifier.optional() }).strict() + .superRefine((value, context) => { + const cursors = Number(value.next_before !== undefined) + Number(value.next_after !== undefined); + if ((value.has_more && cursors !== 1) || (!value.has_more && cursors !== 0)) { + context.addIssue({ code: z.ZodIssueCode.custom, message: "invalid machine read page" }); + } + }) + }).strict() +}).strict(); +const cancelResult = z.object({ target_correlation_id: identifier, state: z.enum(["canceled", "already_final", "not_found"]) }).strict(); + +const duplicateFreeJson = (line: string): unknown => { + let index = 0; + const whitespace = () => { while (/\s/u.test(line[index] ?? "")) index++; }; + const string = (): string => { + const start = index++; let escaped = false; + while (index < line.length) { + const character = line[index++]!; + if (escaped) { escaped = false; continue; } + if (character === "\\") { escaped = true; continue; } + if (character === '"') return JSON.parse(line.slice(start, index)) as string; + } + throw new MoltnetMachineError("malformed machine JSONL"); + }; + const value = (): void => { + whitespace(); const current = line[index]; + if (current === '"') { string(); return; } + if (current === "{") { + index++; const keys = new Set(); whitespace(); + while (line[index] !== "}") { + if (line[index] !== '"') throw new MoltnetMachineError("malformed machine JSONL"); + const key = string(); if (keys.has(key)) throw new MoltnetMachineError("duplicate machine JSON key"); + keys.add(key); whitespace(); if (line[index++] !== ":") throw new MoltnetMachineError("malformed machine JSONL"); + value(); whitespace(); if (line[index] === ",") { index++; whitespace(); } else if (line[index] !== "}") throw new MoltnetMachineError("malformed machine JSONL"); + } + index++; return; + } + if (current === "[") { index++; whitespace(); while (line[index] !== "]") { value(); whitespace(); if (line[index] === ",") { index++; whitespace(); } else if (line[index] !== "]") throw new MoltnetMachineError("malformed machine JSONL"); } index++; return; } + const scalar = /^(?:true|false|null|-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?)/u.exec(line.slice(index)); + if (!scalar) throw new MoltnetMachineError("malformed machine JSONL"); + index += scalar[0].length; + }; + value(); whitespace(); + if (index !== line.length) throw new MoltnetMachineError("malformed machine JSONL"); + try { return JSON.parse(line) as unknown; } catch { throw new MoltnetMachineError("malformed machine JSONL"); } +}; + +const expectRecord = (value: unknown): Record => { + if (!value || typeof value !== "object" || Array.isArray(value)) throw new MoltnetMachineError("invalid machine response"); + return value as Record; +}; + +export const encodeMoltnetMachineRequest = (request: MoltnetMachineRequest): string => { + const allowed = request.operation === "send_nudge" ? ["version", "correlation_id", "operation", "send_nudge"] + : request.operation === "read" ? ["version", "correlation_id", "operation", "read"] + : request.operation === "cancel" ? ["version", "correlation_id", "operation", "cancel"] : []; + const exact = Object.keys(request).length === allowed.length && allowed.every((key) => Object.hasOwn(request, key)); + const payload = request.operation === "send_nudge" ? send.safeParse(request.send_nudge) + : request.operation === "read" ? read.safeParse(request.read) : cancel.safeParse(request.cancel); + if (request.version !== MOLTNET_MACHINE_VERSION || !exact || !identifier.safeParse(request.correlation_id).success || !payload.success) { + throw new MoltnetMachineError("invalid machine request"); + } + const line = JSON.stringify({ version: MOLTNET_MACHINE_VERSION, correlation_id: request.correlation_id, operation: request.operation, [request.operation]: payload.data }); + if (Buffer.byteLength(line) > MOLTNET_MACHINE_MAX_LINE_BYTES) throw new MoltnetMachineError("machine request exceeds line limit"); + return line; +}; + +export const decodeMoltnetMachineTerminal = (line: string): MoltnetMachineTerminal => { + if (Buffer.byteLength(line) > MOLTNET_MACHINE_MAX_LINE_BYTES) throw new MoltnetMachineError("machine response exceeds line limit"); + const raw = expectRecord(duplicateFreeJson(line)); + const keys = Object.keys(raw); const allowed = new Set(["version", "correlation_id", "operation", "send_nudge", "read", "cancel", "error"]); + if (keys.some((key) => !allowed.has(key))) throw new MoltnetMachineError("unknown machine response field"); + if (raw.version !== MOLTNET_MACHINE_VERSION || !identifier.safeParse(raw.correlation_id).success) throw new MoltnetMachineError("machine response version or correlation mismatch"); + if (raw.operation !== "send_nudge" && raw.operation !== "read" && raw.operation !== "cancel") throw new MoltnetMachineError("non-terminal machine response"); + const operation = raw.operation; const payloadKey = operation; + const payloads = [raw.send_nudge, raw.read, raw.cancel, raw.error].filter((value) => value !== undefined); + if (payloads.length !== 1 || (raw[payloadKey] === undefined && raw.error === undefined)) throw new MoltnetMachineError("invalid machine terminal response"); + if (raw.error !== undefined && !error.safeParse(raw.error).success) throw new MoltnetMachineError("invalid machine error response"); + const result = operation === "send_nudge" ? sendResult : operation === "read" ? readResult : cancelResult; + if (raw[payloadKey] !== undefined && !result.safeParse(raw[payloadKey]).success) throw new MoltnetMachineError("invalid machine result response"); + return raw as MoltnetMachineTerminal; +}; diff --git a/src/moltnet/machine/types.ts b/src/moltnet/machine/types.ts new file mode 100644 index 0000000..2d2f291 --- /dev/null +++ b/src/moltnet/machine/types.ts @@ -0,0 +1,52 @@ +export const MOLTNET_MACHINE_VERSION = "moltnet.machine.v1"; +/** SHA-256 of Moltnet 4231bb0's canonical machine-contract artifact. */ +export const MOLTNET_MACHINE_CONTRACT_SHA256 = "1ed6bdc3a9600fd5fc55052d4ba20d1c3d13a7e37daf0465b4543ff5bc5cc64d"; +export const MOLTNET_MACHINE_MAX_LINE_BYTES = 16_384; +export const MOLTNET_MACHINE_MAX_ACTIVE_REQUESTS = 512; + +export type MoltnetMachineOperation = "send_nudge" | "read" | "cancel"; +export type MoltnetMachineTarget = Readonly<{ kind: "room" | "dm"; id: string }>; + +export type MoltnetMachineSendNudge = Readonly<{ + delivery_id: string; + target: MoltnetMachineTarget; + body: string; + origin_message_id?: string; + cause_event_ids?: readonly string[]; +}>; +export type MoltnetMachineRead = Readonly<{ + target: MoltnetMachineTarget; + limit: number; + before?: string; + after?: string; +}>; +export type MoltnetMachineCancel = Readonly<{ target_correlation_id: string }>; + +export type MoltnetMachineRequest = Readonly<{ + version: typeof MOLTNET_MACHINE_VERSION; + correlation_id: string; + operation: MoltnetMachineOperation; + send_nudge?: MoltnetMachineSendNudge; + read?: MoltnetMachineRead; + cancel?: MoltnetMachineCancel; +}>; + +export type MoltnetMachineErrorCode = + | "invalid_request" | "duplicate_request" | "unsupported" | "not_found" + | "conflict" | "capacity" | "transport" | "canceled"; +export type MoltnetMachineTerminal = Readonly<{ + version: typeof MOLTNET_MACHINE_VERSION; + correlation_id: string; + operation: MoltnetMachineOperation; + send_nudge?: Readonly>; + read?: Readonly>; + cancel?: Readonly>; + error?: Readonly<{ code: MoltnetMachineErrorCode }>; +}>; + +export class MoltnetMachineError extends Error { + public constructor(message: string) { + super(message); + this.name = "MoltnetMachineError"; + } +} diff --git a/src/moltnet/root.ts b/src/moltnet/root.ts new file mode 100644 index 0000000..29fcbfb --- /dev/null +++ b/src/moltnet/root.ts @@ -0,0 +1,2 @@ +export * from "./world-participant.js"; +export * from "./transcript-export.js"; diff --git a/src/moltnet/types.test.ts b/src/moltnet/types.test.ts new file mode 100644 index 0000000..3007265 --- /dev/null +++ b/src/moltnet/types.test.ts @@ -0,0 +1,75 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; + +import { ensurePublicPackageBuild } from "../publicPackageBuild.test-helper.js"; +const packageRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url))); +const tscPath = path.join(packageRoot, "node_modules", "typescript", "bin", "tsc"); +const execFileAsync = promisify(execFile); + +async function assertMissingRootExport(compile: Promise, diagnosticCode: "TS2305" | "TS2724", symbol: string): Promise { + let error: { stdout?: string; stderr?: string } | undefined; + try { + await compile; + } catch (caught) { + error = caught as { stdout?: string; stderr?: string }; + } + assert.ok(error, `expected simfile root import for ${symbol} to fail TypeScript compilation`); + const diagnostics = `${error.stdout ?? ""}${error.stderr ?? ""}`; + assert.match(diagnostics, new RegExp(`${diagnosticCode}: .*['\"]${symbol}['\"]`, "u")); +} + +test("MoltnetRoomMessage is an erased simfile/moltnet type and is not root-exported", async () => { + const consumerRoot = await mkdtemp(path.join(tmpdir(), "simfile-moltnet-type-consumer-")); + try { + await ensurePublicPackageBuild(packageRoot); + await mkdir(path.join(consumerRoot, "node_modules"), { recursive: true }); + await symlink(packageRoot, path.join(consumerRoot, "node_modules", "simfile"), "dir"); + const sourcePath = path.join(consumerRoot, "consumer.mts"); + await writeFile(sourcePath, [ + 'import type { MoltnetRoomMessage } from "simfile/moltnet";', + 'const message: MoltnetRoomMessage = { id: "message", from: { id: "agent" }, parts: [] };', + 'if (message.from.id !== "agent") throw new Error("wrong message shape");' + ].join("\n")); + const outputDirectory = path.join(consumerRoot, "out"); + await execFileAsync(process.execPath, [tscPath, "--pretty", "false", "--target", "ES2023", "--module", "NodeNext", "--moduleResolution", "NodeNext", "--strict", "--skipLibCheck", "--rootDir", consumerRoot, "--outDir", outputDirectory, sourcePath], { cwd: consumerRoot }); + const emitted = await readFile(path.join(outputDirectory, "consumer.mjs"), "utf8"); + assert.doesNotMatch(emitted, /simfile/u); + const forbiddenTypeSource = path.join(consumerRoot, "forbidden-root-type.mts"); + await writeFile(forbiddenTypeSource, 'import type { MoltnetRoomMessage } from "simfile";\nconst message: MoltnetRoomMessage = { id: "message", from: { id: "agent" }, parts: [] };\nvoid message;\n'); + await assertMissingRootExport( + execFileAsync(process.execPath, [tscPath, "--pretty", "false", "--target", "ES2023", "--module", "NodeNext", "--moduleResolution", "NodeNext", "--strict", "--skipLibCheck", "--noEmit", forbiddenTypeSource], { cwd: consumerRoot }), + "TS2305", + "MoltnetRoomMessage" + ); + for (const [specifier, diagnosticCode] of [["simfile", "TS2305"], ["simfile/moltnet", "TS2724"]] as const) { + const forbiddenValueSource = path.join(consumerRoot, `forbidden-polling-value-${specifier.replace("/", "-")}.mts`); + await writeFile(forbiddenValueSource, `import { listMoltnetRoomMessages } from "${specifier}";\nvoid listMoltnetRoomMessages;\n`); + await assertMissingRootExport( + execFileAsync(process.execPath, [tscPath, "--pretty", "false", "--target", "ES2023", "--module", "NodeNext", "--moduleResolution", "NodeNext", "--strict", "--skipLibCheck", "--noEmit", forbiddenValueSource], { cwd: consumerRoot }), + diagnosticCode, + "listMoltnetRoomMessages" + ); + } + for (const specifier of ["simfile", "simfile/moltnet"]) { + const forbiddenPollingTypeSource = path.join(consumerRoot, `forbidden-polling-type-${specifier.replace("/", "-")}.mts`); + await writeFile(forbiddenPollingTypeSource, `import type { MoltnetRoomClientOptions } from "${specifier}";\nconst options: MoltnetRoomClientOptions = {};\nvoid options;\n`); + await assertMissingRootExport( + execFileAsync(process.execPath, [tscPath, "--pretty", "false", "--target", "ES2023", "--module", "NodeNext", "--moduleResolution", "NodeNext", "--strict", "--skipLibCheck", "--noEmit", forbiddenPollingTypeSource], { cwd: consumerRoot }), + "TS2305", + "MoltnetRoomClientOptions" + ); + } + const root = await import(path.join(packageRoot, "dist", "index.js")); + const moltnet = await import(path.join(packageRoot, "dist", "moltnet", "index.js")); + assert.equal("MoltnetRoomMessage" in root, false); + assert.equal("MoltnetRoomMessage" in moltnet, false); + } finally { + await rm(consumerRoot, { force: true, recursive: true }); + } +}); diff --git a/src/moltnet/types.ts b/src/moltnet/types.ts new file mode 100644 index 0000000..20ff9fa --- /dev/null +++ b/src/moltnet/types.ts @@ -0,0 +1,17 @@ +export interface MoltnetRoomMessage { + id: string; + network_id?: string; + origin?: { network_id: string; message_id: string }; + target?: { + kind: string; + room_id?: string; + thread_id?: string; + parent_message_id?: string; + dm_id?: string; + participant_ids?: string[]; + }; + from: { id: string; type?: string; name?: string; network_id?: string; fqid?: string }; + parts: { kind: string; text?: string; url?: string; data?: unknown; filename?: string; media_type?: string }[]; + mentions?: string[]; + created_at?: string; +} diff --git a/src/moltnet/world-participant.test.ts b/src/moltnet/world-participant.test.ts index 56222a6..c7cdaef 100644 --- a/src/moltnet/world-participant.test.ts +++ b/src/moltnet/world-participant.test.ts @@ -138,7 +138,7 @@ const startFakeMoltnetServer = async (): Promise<{ }; describe("Simfile Moltnet world participant", () => { - it("posts world.message, world.dm, and wake.recommended events with event id metadata", async () => { + it("posts only explicit world.message and world.dm events with event id metadata", async () => { const simfile = parseSimfileSource(` simfile_version: "0.1" name: world-participant-world @@ -156,15 +156,12 @@ rules: - action: moltnet:dm to: agent:alice content: "Private alert for alice" - - action: wake:recommend - to: room:office-floor:ops-room `, { path: "Simfile.yaml" }).simfile; const trace = runSimfileTrace(simfile, { runId: "run-world", seed: "seed", ticks: 1 }); const worldEvents = trace.events.filter(isWorldMessageEvent); - assert.equal(worldEvents.length, 3); + assert.equal(worldEvents.length, 2); assert.deepEqual(worldEvents.map((event) => event.kind).sort(), [ - "wake.recommended", "world.dm", "world.message" ]); @@ -176,8 +173,8 @@ rules: baseUrl, networkId: "office-floor" }); - assert.equal(results.length, 3); - assert.equal(requests.length, 3); + assert.equal(results.length, 2); + assert.equal(requests.length, 2); assert.equal( requests.every((entry) => entry.path === "/v1/messages"), true @@ -195,7 +192,7 @@ rules: } return payload.target.kind; }).sort(); - assert.deepEqual(kinds, ["dm", "room", "room"]); + assert.deepEqual(kinds, ["dm", "room"]); const eventIds = new Set(worldEvents.map((event) => event.event_id)); for (const payload of payloads) { @@ -218,7 +215,7 @@ rules: const roomTargets = payloads .map((payload) => (payload.target as { kind?: string; room_id?: string }).room_id) .filter((roomId): roomId is string => typeof roomId === "string"); - assert.deepEqual(roomTargets, ["ops-room", "ops-room"]); + assert.deepEqual(roomTargets, ["ops-room"]); const dmTarget = payloads .map((payload) => payload.target as { kind?: string; dm_id?: string; participant_ids?: unknown }) @@ -271,4 +268,28 @@ rules: return assert.rejects(() => sendWorldEventToMoltnet(unsupportedEvent, { baseUrl: "http://127.0.0.1:1" })); }); + + it("cannot build or send a targeted recommendation event", async () => { + let fetchCalls = 0; + const recommendation = { + event_id: "run-id:recommendation", + kind: "wake.recommended", + sim_time: 0, + provenance: "mechanical", + actor: "rule", + target: "room:network:operations", + scope: "room:network:operations", + payload: { recipient: "agent:member" } + } as const; + + await assert.rejects(() => sendWorldEventToMoltnet(recommendation, { + baseUrl: "http://127.0.0.1:1", + fetchFn: async () => { + fetchCalls += 1; + return new Response(null, { status: 202 }); + } + }), /unsupported event kind/u); + assert.equal(fetchCalls, 0); + assert.equal(isWorldMessageEvent(recommendation), false); + }); }); diff --git a/src/moltnet/world-participant.ts b/src/moltnet/world-participant.ts index 74f62ce..468da66 100644 --- a/src/moltnet/world-participant.ts +++ b/src/moltnet/world-participant.ts @@ -1,6 +1,6 @@ import type { RuntimeTraceEvent } from "../runtime/types.js"; -export const WORLD_MESSAGE_EVENT_KINDS = ["world.message", "world.dm", "wake.recommended"] as const; +export const WORLD_MESSAGE_EVENT_KINDS = ["world.message", "world.dm"] as const; type WorldMessageEventKind = (typeof WORLD_MESSAGE_EVENT_KINDS)[number]; const WORLD_ACTOR_ID = "world"; @@ -120,7 +120,7 @@ const defaultDmId = (network: string, agentId: string, event: RuntimeTraceEvent) const deriveActorNetworkId = (event: RuntimeTraceEvent, options: MoltnetWorldDeliveryOptions): string | undefined => { if (options.networkId) return options.networkId; - if (event.kind === "world.message" || event.kind === "wake.recommended") { + if (event.kind === "world.message") { return asRoomScope(event.target).network; } return undefined; diff --git a/src/observe/AGENTS.md b/src/observe/AGENTS.md index 7b6a166..483aaf9 100644 --- a/src/observe/AGENTS.md +++ b/src/observe/AGENTS.md @@ -13,9 +13,15 @@ Spawnfile internals; the only cross-repo dependency is the narrow shared package - `report.ts` — the `simfile.observe.v1` zod schema/type + `parseObserveReport`. - `artifacts.ts` — `verifyManifestArtifacts`: sha256-checks every manifest-declared artifact against the file on disk. A mismatch is reported, never silently repaired. -- `causalStreams.ts` — `collectCausalStreams`: walks `/raw/**/causal.jsonl`, - tags each stream by its authority directory (`raw//...`), and parses it - with `@noopolis/stele`'s `parseCausalJsonl`. +- `rawFiles.ts` — the shared raw-artifact locator. It preserves the historical + top-level `raw/**` tree and admits exact nested raw artifacts only when the + sealed manifest names them (for example a composed `organization/raw/**` + export), exposing an honest run-relative path plus its `raw/`-relative + authority path. +- `causalStreams.ts` — `collectCausalStreams`: reads every raw locator result + ending in `causal.jsonl`, tags it by the authority directly below its own + `raw/` namespace, and parses it with `@noopolis/stele`'s + `parseCausalJsonl`. - `memoryBanks.ts` — `collectMemoryBankCounts`: ledger-first (Slice B Piece 4b). Derives each bank's memory-write count from `memory.written` causal events (mneme's write-side envelope, reconciled alongside `memory.recalled`) when at @@ -34,7 +40,11 @@ Spawnfile internals; the only cross-repo dependency is the narrow shared package `failures` (`turn.failed`/`wake.failed` events, plus any `seedSpread.ts` exclusion). `buildObserveReport` takes an optional `seedSpread` input (memetics increment (b)) and folds it into `seed_spread`/`spread_summary` - when present — omitted entirely for a manifest without `seed_declaration`. + when present — omitted entirely for a manifest without `seed_declaration`; + it also folds a recorded manifest world-grant marker into `world_grants`. +- `worldGrants.ts` — validates and folds the manifest's optional + `world.world_grants` marker into observer evidence while preserving absence + as distinct from `none-declared`. - `seedSpreadArtifacts.ts` — I/O-only reads `seedSpread.ts`'s re-derivation needs beyond `causalStreams.ts`/`memoryBanks.ts`: `readSpreadTranscriptMessages` (every `transcript.json` under `raw/moltnet/**`, flattened to `{id, fromId, @@ -57,13 +67,17 @@ Spawnfile internals; the only cross-repo dependency is the narrow shared package agent's own appearances. `diffSeedSpreadAgainstLiveMarkerSeen` is a diagnostic-only self-check against the live world loop's own `marker.seen` events (`spreadSelfCheck` on `ObserveResult`, never fed into the report - itself — `worldTickLoop.ts`'s own doc comment: polling order ≠ causal order). + itself — live polling order is not causal order). - `spreadMatcher.ts` — pure policy parser and matcher for seed spread. `exact` reuses `../ledger/markers.ts`'s word-boundary matching; `edit-distance` uses local Levenshtein scoring; model-backed policies fail loudly as unsupported. - `observe.ts` — `runObserve(runDir)` orchestrates the above and returns the report plus artifact-integrity/parse-error/spread-self-check diagnostics; `writeObserveReport` writes `/observe/report.json`. +- `summaryLines.ts` — pure rendering of the plain-text observe summary, + including the three world-grant output forms. +- `observeCommand.ts` — the CLI adapter for `simfile observe`; owns its + argument parsing, warnings, output selection, and exit code. - `index.ts` — barrel. ## Rules diff --git a/src/observe/causalStreams.ts b/src/observe/causalStreams.ts index 7ed122c..dcda829 100644 --- a/src/observe/causalStreams.ts +++ b/src/observe/causalStreams.ts @@ -1,10 +1,11 @@ -import { readdir, readFile } from "node:fs/promises"; -import path from "node:path"; +import { readFile } from "node:fs/promises"; import { parseCausalJsonl, type CausalEvent, type CausalJsonlParseError } from "@noopolis/stele"; +import { findRunRawFiles, rawAuthority } from "./rawFiles.js"; + export interface CausalStreamSource { - /** The authority directory name directly under `raw/` (e.g. "moltnet", "daimon", "mneme"). */ + /** The authority directory name directly under a `raw/` namespace. */ authority: string; errors: CausalJsonlParseError[]; events: CausalEvent[]; @@ -12,39 +13,22 @@ export interface CausalStreamSource { relativePath: string; } -const walkFiles = async (root: string): Promise => { - const entries = await readdir(root, { withFileTypes: true }).catch(() => []); - const files: string[] = []; - for (const entry of entries) { - const entryPath = path.join(root, entry.name); - if (entry.isDirectory()) { - files.push(...(await walkFiles(entryPath))); - } else if (entry.isFile()) { - files.push(entryPath); - } - } - return files; -}; - /** - * Reads and parses every `causal.jsonl` file under `/raw/**`, - * tagging each stream by the authority directory it lives under - * (`raw//...causal.jsonl`). Never reads any other file kind here - * — that keeps this module a single-purpose causal-stream collector; other - * per-authority artifacts (transcripts, bank event logs) are read by their - * own modules. + * Reads and parses every discoverable `raw//.../causal.jsonl`. + * Top-level raw trees retain their legacy behavior; nested raw artifacts are + * admitted only through the sealed manifest (`rawFiles.ts`). Never reads any + * other file kind here. */ export const collectCausalStreams = async (runDir: string): Promise => { - const rawDir = path.join(runDir, "raw"); - const files = (await walkFiles(rawDir)).filter((file) => file.endsWith("causal.jsonl")).sort(); + const files = (await findRunRawFiles(runDir)) + .filter(({ rawRelativePath }) => rawRelativePath.endsWith("causal.jsonl")); const sources: CausalStreamSource[] = []; for (const file of files) { - const relativePath = path.relative(runDir, file); - const authority = relativePath.split(path.sep)[1] ?? "unknown"; - const text = await readFile(file, "utf8"); + const authority = rawAuthority(file.rawRelativePath); + const text = await readFile(file.absolutePath, "utf8"); const { errors, events } = parseCausalJsonl(text); - sources.push({ authority, errors, events, relativePath }); + sources.push({ authority, errors, events, relativePath: file.relativePath }); } return sources; }; diff --git a/src/observe/compute.ts b/src/observe/compute.ts index 5906fca..ffd1023 100644 --- a/src/observe/compute.ts +++ b/src/observe/compute.ts @@ -4,6 +4,9 @@ import type { MemoryBankCounts } from "./memoryBanks.js"; import type { SimfileRunManifest } from "./manifest.js"; import { INCOMPLETE_CHAIN_FLAGS, OBSERVE_REPORT_VERSION, type SimfileObserveReport } from "./report.js"; import type { SeedSpreadComputeResult } from "./seedSpread.js"; +import { worldGrantsFromManifest } from "./worldGrants.js"; +import type { WorldEvidence } from "./worldEvidence.js"; +import type { SocialPlane } from "./socialPlane.js"; const FAILURE_TYPES = new Set(["turn.failed", "wake.failed"]); const AGENT_PRINCIPAL_PATTERN = /^agent:(.+)$/u; @@ -109,25 +112,33 @@ export interface BuildObserveReportInput { * exists. `excluded` hits (instrument/operator actors) fold into * `failures`, never into `seed_spread`. */ seedSpread?: SeedSpreadComputeResult; + worldEvidence?: WorldEvidence; + socialPlane?: SocialPlane; } -export const buildObserveReport = (input: BuildObserveReportInput): SimfileObserveReport => ({ - version: OBSERVE_REPORT_VERSION, - run_id: input.manifest.run_id, - contract_versions: input.manifest.contract_versions, - participants: computeParticipants(input.allEvents), - agent_turns: computeAgentTurns(input.allEvents), - chains: computeChains(input.reconciled), - memory: input.memoryBanks.map((bank) => ({ - bank: bank.bank, - events: bank.events, - recalls: bank.recalls, - memory_write_source: bank.memory_write_source, - ...(bank.writes_by_agent ? { writes_by_agent: bank.writes_by_agent } : {}) - })), - failures: [ - ...computeFailures(input.allEvents), - ...(input.seedSpread?.excluded.map((excluded) => ({ event_id: excluded.event_id, reason: excluded.reason })) ?? []) - ], - ...(input.seedSpread ? { seed_spread: input.seedSpread.entries, spread_summary: input.seedSpread.summary } : {}) -}); +export const buildObserveReport = (input: BuildObserveReportInput): SimfileObserveReport => { + const worldGrants = worldGrantsFromManifest(input.manifest); + return { + version: OBSERVE_REPORT_VERSION, + run_id: input.manifest.run_id, + contract_versions: input.manifest.contract_versions, + participants: computeParticipants(input.allEvents), + agent_turns: computeAgentTurns(input.allEvents), + chains: computeChains(input.reconciled), + memory: input.memoryBanks.map((bank) => ({ + bank: bank.bank, + events: bank.events, + recalls: bank.recalls, + memory_write_source: bank.memory_write_source, + ...(bank.writes_by_agent ? { writes_by_agent: bank.writes_by_agent } : {}) + })), + failures: [ + ...computeFailures(input.allEvents), + ...(input.seedSpread?.excluded.map((excluded) => ({ event_id: excluded.event_id, reason: excluded.reason })) ?? []) + ], + ...(input.seedSpread ? { seed_spread: input.seedSpread.entries, spread_summary: input.seedSpread.summary } : {}), + ...(worldGrants === undefined ? {} : { world_grants: worldGrants }), + ...(input.worldEvidence === undefined ? {} : { world_evidence: input.worldEvidence }) + ,...(input.socialPlane === undefined ? {} : { social_plane: input.socialPlane }) + }; +}; diff --git a/src/observe/index.ts b/src/observe/index.ts index e1a4780..5ee1b56 100644 --- a/src/observe/index.ts +++ b/src/observe/index.ts @@ -1,10 +1,7 @@ -export * from "./artifacts.js"; -export * from "./causalStreams.js"; -export * from "./compute.js"; -export * from "./manifest.js"; -export * from "./memoryBanks.js"; -export * from "./observe.js"; -export * from "./report.js"; -export * from "./seedSpread.js"; -export * from "./seedSpreadArtifacts.js"; -export * from "./spreadMatcher.js"; +export { + parseRunManifest, + type RunManifestArtifactEntry, + RUN_MANIFEST_VERSION, + type SimfileRunManifest +} from "./manifest.js"; +export { runObserve } from "./observe.js"; diff --git a/src/observe/memoryBanks.ts b/src/observe/memoryBanks.ts index 2ef297e..7006934 100644 --- a/src/observe/memoryBanks.ts +++ b/src/observe/memoryBanks.ts @@ -1,9 +1,10 @@ -import { readdir, readFile } from "node:fs/promises"; +import { readFile, readdir } from "node:fs/promises"; import path from "node:path"; import type { CausalEvent } from "@noopolis/stele"; import type { MemoryWriteSource } from "./report.js"; +import { findRunRawFiles, rawBank } from "./rawFiles.js"; export interface MemoryBankCounts { bank: string; @@ -71,18 +72,32 @@ export const collectMemoryBankCounts = async ( runDir: string, causalEventsByBank: ReadonlyMap ): Promise => { - const mnemeDir = path.join(runDir, "raw", "mneme"); - const bankDirs = await readdir(mnemeDir, { withFileTypes: true }).catch(() => []); + const rawFiles = await findRunRawFiles(runDir); + const topLevelBanks = (await readdir(path.join(runDir, "raw", "mneme"), + { withFileTypes: true }).catch(() => [])) + .filter((entry) => entry.isDirectory()) + .map((entry) => entry.name); + const banks = new Set([ + ...causalEventsByBank.keys(), + ...topLevelBanks, + ...rawFiles.flatMap(({ rawRelativePath }) => { + const bank = rawBank(rawRelativePath); + return bank === undefined ? [] : [bank]; + }), + ]); const results: MemoryBankCounts[] = []; - for (const entry of bankDirs) { - if (!entry.isDirectory()) continue; - const bank = entry.name; + for (const bank of banks) { const bankCausalEvents = causalEventsByBank.get(bank) ?? []; - - const eventsPath = path.join(mnemeDir, bank, "events.jsonl"); - const eventsText = await readFile(eventsPath, "utf8").catch(() => null); - const eventsJsonlLines = eventsText !== null ? countJsonlLines(eventsText) : null; + const eventFiles = rawFiles.filter(({ rawRelativePath }) => { + const segments = rawRelativePath.split(path.sep); + return segments.length === 4 && rawBank(rawRelativePath) === bank + && segments[3] === "events.jsonl"; + }); + const eventsJsonlLines = eventFiles.length === 0 + ? null + : (await Promise.all(eventFiles.map(({ absolutePath }) => + readFile(absolutePath, "utf8")))).flatMap(countJsonlLines); const recalls = eventsJsonlLines !== null diff --git a/src/observe/observe.test.ts b/src/observe/observe.test.ts index f41b538..411cc82 100644 --- a/src/observe/observe.test.ts +++ b/src/observe/observe.test.ts @@ -153,7 +153,7 @@ describe("runObserve — ledger-writes synthetic fixture (Slice B Piece 4b)", () /** * Memetics increment (b)'s own golden fixture: a REAL captured - * `runWorldDrivenOfficeSim` run (real `spawnfile up`/Docker, scripted + * fixture-owned composed run (real `spawnfile up`/Docker, scripted * engine, no LLM auth) against `fixtures/sims/office-secret-v0/`. Eleanor's * seeded `MEMORY.md` line ("Rosa Delgado is the referral client...") makes * it into her own room utterance, which Sam echoes back — the transcript's diff --git a/src/observe/observe.ts b/src/observe/observe.ts index a56de2c..b88a5ac 100644 --- a/src/observe/observe.ts +++ b/src/observe/observe.ts @@ -12,12 +12,15 @@ import { collectMemoryBankCounts } from "./memoryBanks.js"; import type { SimfileRunManifest } from "./manifest.js"; import { parseRunManifest } from "./manifest.js"; import type { SimfileObserveReport } from "./report.js"; +import { rawBank } from "./rawFiles.js"; import { computeSeedSpread, diffSeedSpreadAgainstLiveMarkerSeen, type SeedSpreadSelfCheck } from "./seedSpread.js"; import { readSpreadTranscriptMessages, readTickByIngestedMessageId, readSpreadMnemeEventsByBank } from "./seedSpreadArtifacts.js"; +import { readWorldEvidence } from "./worldEvidence.js"; +import { computeSocialPlane, readSocialTranscript } from "./socialPlane.js"; export interface ObserveResult { artifactIntegrity: ArtifactIntegrityCheck[]; @@ -36,12 +39,6 @@ const loadRunManifest = async (runDir: string): Promise => { return parseRunManifest(raw); }; -const bankFromRelativePath = (relativePath: string): string | undefined => { - // raw/mneme//causal.jsonl - const segments = relativePath.split(path.sep); - return segments[0] === "raw" && segments[1] === "mneme" ? segments[2] : undefined; -}; - /** * Reads a sealed run directory (`manifest.json` + `raw/**\/causal.jsonl` + * per-bank `raw/mneme//events.jsonl`), reconciles the causal streams @@ -64,7 +61,7 @@ export const runObserve = async (runDir: string): Promise => { const eventsByBank = new Map(); for (const stream of streams) { - const bank = bankFromRelativePath(stream.relativePath); + const bank = rawBank(stream.relativePath); if (!bank) continue; eventsByBank.set(bank, [...(eventsByBank.get(bank) ?? []), ...stream.events]); } @@ -98,9 +95,18 @@ export const runObserve = async (runDir: string): Promise => { ); } - const report = buildObserveReport({ allEvents, manifest, memoryBanks, reconciled, seedSpread }); + const worldEvidenceResult = await readWorldEvidence(runDir, allEvents); + const socialPlane = computeSocialPlane(await readSocialTranscript(runDir), allEvents); + const report = buildObserveReport({ allEvents, manifest, memoryBanks, reconciled, seedSpread, worldEvidence: worldEvidenceResult.evidence, socialPlane }); - return { artifactIntegrity, causalParseErrors, manifest, report, streams, spreadSelfCheck }; + return { + artifactIntegrity, + causalParseErrors: [...causalParseErrors, ...worldEvidenceResult.parseErrors], + manifest, + report, + streams, + spreadSelfCheck + }; }; /** Writes `observe/report.json` under the run directory; returns its path. */ diff --git a/src/observe/observeCommand.ts b/src/observe/observeCommand.ts new file mode 100644 index 0000000..fc0ecb9 --- /dev/null +++ b/src/observe/observeCommand.ts @@ -0,0 +1,80 @@ +import { resolve } from "node:path"; + +import { runObserve, writeObserveReport } from "./observe.js"; +import { observeSummaryLines } from "./summaryLines.js"; + +export interface ObserveCommandHelpers { + formatError: (error: unknown) => string; + usage: () => string; +} + +interface ParsedObserveOptions { + json?: boolean; + runDir?: string; +} + +const parseObserveArguments = (argv: readonly string[]): { error?: string; options?: ParsedObserveOptions } => { + const options: ParsedObserveOptions = {}; + + for (const arg of argv) { + if (arg === "--json") { + options.json = true; + continue; + } + if (arg.startsWith("-")) { + return { error: `Unknown flag ${arg}` }; + } + if (options.runDir !== undefined) { + return { error: `Unexpected positional argument ${arg}` }; + } + options.runDir = arg; + } + + if (!options.runDir) return { error: "Missing run directory" }; + return { options }; +}; + +export const runObserveCommand = async ( + argv: readonly string[], + helpers: ObserveCommandHelpers +): Promise => { + const parsed = parseObserveArguments(argv); + if (parsed.error || !parsed.options?.runDir) { + if (parsed.error) process.stderr.write(`${parsed.error}\n`); + process.stderr.write(helpers.usage()); + return 1; + } + + const runDir = resolve(parsed.options.runDir); + try { + const result = await runObserve(runDir); + const reportPath = await writeObserveReport(runDir, result.report); + + const failedArtifacts = result.artifactIntegrity.filter((check) => !check.ok); + for (const failed of failedArtifacts) { + process.stderr.write( + `warning: artifact sha256 mismatch for ${failed.path} (expected ${failed.expectedSha256}, got ${failed.actualSha256 ?? ""})\n` + ); + } + for (const parseError of result.causalParseErrors) { + process.stderr.write(`warning: ${parseError.relativePath}:${parseError.line}: ${parseError.message}\n`); + } + + if (parsed.options.json) { + process.stdout.write(`${JSON.stringify({ + artifactIntegrity: result.artifactIntegrity, + causalParseErrors: result.causalParseErrors, + report: result.report, + reportPath + }, null, 2)}\n`); + } else { + for (const line of observeSummaryLines(result.report, reportPath)) { + process.stdout.write(`${line}\n`); + } + } + return failedArtifacts.length > 0 ? 1 : 0; + } catch (error) { + process.stderr.write(`${helpers.formatError(error)}\n`); + return 1; + } +}; diff --git a/src/observe/publicSurface.test.ts b/src/observe/publicSurface.test.ts new file mode 100644 index 0000000..b5c4d1d --- /dev/null +++ b/src/observe/publicSurface.test.ts @@ -0,0 +1,73 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import test from "node:test"; + +import { ensurePublicPackageBuild } from "../publicPackageBuild.test-helper.js"; +import * as observe from "./index.js"; + +const packageRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url))); +const tscPath = path.join(packageRoot, "node_modules", "typescript", "bin", "tsc"); +const execFileAsync = promisify(execFile); + +const publicValues = [ + "parseRunManifest", + "RUN_MANIFEST_VERSION", + "runObserve" +].sort(); + +const publicTypes = [ + "RunManifestArtifactEntry", + "SimfileRunManifest" +].sort(); + +test("observe public surface is the exact neutral external-consumer contract", async () => { + assert.deepEqual(Object.keys(observe).sort(), publicValues); + const barrel = await readFile(path.join(packageRoot, "src", "observe", "index.ts"), "utf8"); + assert.deepEqual( + [...barrel.matchAll(/type (RunManifestArtifactEntry|SimfileRunManifest)/gu)].map((match) => match[1]).sort(), + publicTypes + ); + assert.doesNotMatch(barrel, /(?:SeedDeclaration|OBSERVE_REPORT_VERSION|ObserveResult|parseObserveReport|writeObserveReport|collectCausalStreams)/u); + + const consumerRoot = await mkdtemp(path.join(tmpdir(), "simfile-observe-consumer-")); + try { + await ensurePublicPackageBuild(packageRoot); + await mkdir(path.join(consumerRoot, "node_modules"), { recursive: true }); + await symlink(packageRoot, path.join(consumerRoot, "node_modules", "simfile"), "dir"); + + const sourcePath = path.join(consumerRoot, "consumer.mts"); + await writeFile(sourcePath, [ + 'import { RUN_MANIFEST_VERSION, parseRunManifest, runObserve } from "simfile/observe";', + 'import type { RunManifestArtifactEntry, SimfileRunManifest } from "simfile/observe";', + 'const artifact: RunManifestArtifactEntry = { path: "raw/events.jsonl", sha256: "a".repeat(64) };', + 'const manifest: SimfileRunManifest = parseRunManifest({ version: RUN_MANIFEST_VERSION, run_id: "run", created_at: "2026-01-01T00:00:00.000Z", contract_versions: {}, artifacts: [artifact] });', + 'if (RUN_MANIFEST_VERSION !== "simfile.run-manifest.v1") throw new Error("wrong manifest version");', + 'if (manifest.artifacts[0]?.path !== artifact.path) throw new Error("wrong artifact");', + 'if (typeof runObserve !== "function") throw new Error("missing observe function");' + ].join("\n")); + const outputDirectory = path.join(consumerRoot, "out"); + await execFileAsync(process.execPath, [ + tscPath, + "--pretty", "false", + "--target", "ES2023", + "--module", "NodeNext", + "--moduleResolution", "NodeNext", + "--strict", + "--skipLibCheck", + "--rootDir", consumerRoot, + "--outDir", outputDirectory, + sourcePath + ], { cwd: consumerRoot }); + const emittedPath = path.join(outputDirectory, "consumer.mjs"); + const emitted = await readFile(emittedPath, "utf8"); + assert.match(emitted, /(?:from|require\()\s*["']simfile\/observe["']/u); + await execFileAsync(process.execPath, [emittedPath], { cwd: consumerRoot }); + } finally { + await rm(consumerRoot, { force: true, recursive: true }); + } +}); diff --git a/src/observe/rawFiles.test.ts b/src/observe/rawFiles.test.ts new file mode 100644 index 0000000..6c9cc95 --- /dev/null +++ b/src/observe/rawFiles.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import { mkdir, mkdtemp, rm, symlink, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { it } from "node:test"; + +import { collectCausalStreams } from "./causalStreams.js"; +import { findRunRawFiles } from "./rawFiles.js"; +import { buildRunTimeline } from "../view/runTimeline.js"; + +const digest = (value: string): string => + createHash("sha256").update(value).digest("hex"); + +const causal = (input: Readonly<{ + causeIds?: string[]; + eventId: string; + messageId?: string; + principal: string; + seq: number; + streamId: string; + system: "daimon" | "moltnet"; + type: string; +}>): string => `${JSON.stringify({ + cause_event_ids: input.causeIds ?? [], + emitter: { seq: input.seq, stream_id: input.streamId, system: input.system }, + event_id: input.eventId, + payload: input.messageId === undefined ? { turn_id: "turn-1" } : { + content_sha256: "a".repeat(64), + message_id: input.messageId, + policy_decision: "accepted", + target: { kind: "room", room_id: "room-1" }, + }, + principal_id: input.principal, + recorded_at: `2026-08-10T00:00:0${input.seq}.000Z`, + run_id: "nested-raw-run", + type: input.type, + version: "noopolis.causal-event.v1", +})}\n`; + +it("reads manifest-declared nested raw evidence without admitting planted sibling files", async () => { + const runDir = await mkdtemp(path.join(tmpdir(), "simfile-nested-raw-")); + const topPath = "raw/moltnet/causal.jsonl"; + const nestedPath = "organization/raw/daimon/agent-a/causal.jsonl"; + const transcriptPath = "organization/raw/moltnet/transcript.json"; + const plantedPath = "shadow/raw/mneme/planted/causal.jsonl"; + const messageId = "message-1"; + const messageEventId = "moltnet:message-1"; + const top = causal({ eventId: messageEventId, messageId, principal: "agent:agent-a", + seq: 1, streamId: "network:net-1", system: "moltnet", type: "message.accepted" }); + const nested = causal({ causeIds: [messageEventId], eventId: "daimon:turn-1", + principal: "agent:agent-a", seq: 2, streamId: "agent:agent-a", + system: "daimon", type: "turn.input.submitted" }); + const transcript = `${JSON.stringify({ + conversations: [{ messages: [{ created_at: "2026-08-10T00:00:01.000Z", + from: { id: "agent-a" }, id: messageId, + parts: [{ kind: "text", text: "nested hello" }] }] }], + version: "moltnet.transcript-export.v1", + })}\n`; + try { + for (const relative of [topPath, nestedPath, transcriptPath, plantedPath]) { + await mkdir(path.dirname(path.join(runDir, relative)), { recursive: true }); + } + await Promise.all([ + writeFile(path.join(runDir, topPath), top), + writeFile(path.join(runDir, nestedPath), nested), + writeFile(path.join(runDir, transcriptPath), transcript), + writeFile(path.join(runDir, plantedPath), nested), + ]); + await writeFile(path.join(runDir, "manifest.json"), JSON.stringify({ + artifacts: [nestedPath, transcriptPath].map((artifactPath) => ({ + path: artifactPath, + sha256: digest(artifactPath === nestedPath ? nested : transcript), + })), + contract_versions: {}, + created_at: "2026-08-10T00:00:00.000Z", + run_id: "nested-raw-run", + version: "simfile.run-manifest.v1", + })); + + const files = await findRunRawFiles(runDir); + assert.deepEqual(files.map(({ relativePath }) => relativePath), + [nestedPath, transcriptPath, topPath]); + assert.ok(files.every(({ relativePath }) => relativePath !== plantedPath)); + + const streams = await collectCausalStreams(runDir); + assert.deepEqual(streams.map(({ authority, relativePath }) => + ({ authority, relativePath })), [ + { authority: "daimon", relativePath: nestedPath }, + { authority: "moltnet", relativePath: topPath }, + ]); + const timeline = await buildRunTimeline(runDir); + assert.equal(timeline.events.length, 2); + assert.equal(timeline.events.find(({ eventId }) => eventId === messageEventId)?.text, + "nested hello"); + assert.ok(timeline.elements.some(({ ref }) => ref === "agent:agent-a")); + } finally { + await rm(runDir, { force: true, recursive: true }); + } +}); + +it("preserves no-manifest top-level reads and fails closed on nested ambiguity or escape", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-raw-boundary-")); + const runDir = path.join(root, "run"); + const outside = path.join(root, "outside.jsonl"); + const topPath = "raw/moltnet/causal.jsonl"; + const nestedPath = "organization/raw/moltnet/causal.jsonl"; + try { + await mkdir(path.dirname(path.join(runDir, topPath)), { recursive: true }); + await writeFile(path.join(runDir, topPath), "top\n"); + assert.deepEqual((await findRunRawFiles(runDir)).map(({ relativePath }) => + relativePath), [topPath]); + + await mkdir(path.dirname(path.join(runDir, nestedPath)), { recursive: true }); + await writeFile(path.join(runDir, nestedPath), "nested\n"); + await writeFile(path.join(runDir, "manifest.json"), JSON.stringify({ + artifacts: [{ path: nestedPath, sha256: digest("nested\n") }], + contract_versions: {}, created_at: "2026-08-10T00:00:00.000Z", + run_id: "raw-boundary", version: "simfile.run-manifest.v1", + })); + await assert.rejects(findRunRawFiles(runDir), /ambiguous raw artifact/u); + + await rm(path.join(runDir, "raw"), { force: true, recursive: true }); + await rm(path.join(runDir, nestedPath), { force: true }); + await writeFile(outside, "outside\n"); + await symlink(outside, path.join(runDir, nestedPath)); + await assert.rejects(findRunRawFiles(runDir), /escapes the run directory/u); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +it("leaves a missing manifest-declared nested artifact to the integrity report", async () => { + const runDir = await mkdtemp(path.join(tmpdir(), "simfile-raw-missing-")); + try { + await writeFile(path.join(runDir, "manifest.json"), JSON.stringify({ + artifacts: [{ + path: "organization/raw/moltnet/missing.jsonl", + sha256: digest("missing\n"), + }], + contract_versions: {}, created_at: "2026-08-10T00:00:00.000Z", + run_id: "raw-missing", version: "simfile.run-manifest.v1", + })); + + assert.deepEqual(await findRunRawFiles(runDir), []); + } finally { + await rm(runDir, { force: true, recursive: true }); + } +}); diff --git a/src/observe/rawFiles.ts b/src/observe/rawFiles.ts new file mode 100644 index 0000000..b27d549 --- /dev/null +++ b/src/observe/rawFiles.ts @@ -0,0 +1,145 @@ +import { readFile, readdir, realpath } from "node:fs/promises"; +import path from "node:path"; + +import { parseRunManifest } from "./manifest.js"; + +/** One raw artifact together with both of the paths its consumers need. */ +export interface RunRawFile { + /** Absolute filesystem path used only for the read. */ + absolutePath: string; + /** Actual path relative to the sealed run directory. */ + relativePath: string; + /** Path beginning at the artifact namespace's `raw/` directory. */ + rawRelativePath: string; +} + +const walkFiles = async (root: string): Promise => { + const entries = await readdir(root, { withFileTypes: true }).catch(() => []); + const files: string[] = []; + for (const entry of entries) { + const entryPath = path.join(root, entry.name); + if (entry.isDirectory()) files.push(...await walkFiles(entryPath)); + else if (entry.isFile()) files.push(entryPath); + } + return files; +}; + +const manifestRelativePath = (value: string): string | undefined => { + if (value.includes("\\") || value.includes("\0") + || path.posix.isAbsolute(value) || path.win32.isAbsolute(value)) return undefined; + const segments = value.split("/"); + if (segments.some((segment) => segment.length === 0 || segment === "." || segment === "..")) { + return undefined; + } + return segments.join(path.sep); +}; + +const rawRelativePath = (relativePath: string): string | undefined => { + const segments = relativePath.split(path.sep); + const rawIndex = segments.indexOf("raw"); + return rawIndex < 0 ? undefined : segments.slice(rawIndex).join(path.sep); +}; + +const isMissing = (error: unknown): boolean => + typeof error === "object" && error !== null + && (error as { code?: unknown }).code === "ENOENT"; + +const isInside = (root: string, candidate: string): boolean => { + const relative = path.relative(root, candidate); + return relative === "" || !(relative === ".." || relative.startsWith(`..${path.sep}`) + || path.isAbsolute(relative)); +}; + +const compareLexically = (left: string, right: string): number => + left < right ? -1 : left > right ? 1 : 0; + +/** + * Finds raw evidence in both supported sealed layouts without knowing any + * scenario vocabulary: + * + * - the historical top-level `raw/**` tree remains discoverable as before; + * - a nested raw namespace is admitted only when the run manifest names the + * exact file (composed records currently use `organization/raw/**`). + * + * The returned `relativePath` never hides the namespace. Consumers use + * `rawRelativePath` only to identify the authority immediately below `raw/`. + */ +export const findRunRawFiles = async (runDir: string): Promise => { + const root = path.resolve(runDir); + const files = new Map(); + const byRawRelative = new Map(); + const add = (file: RunRawFile): void => { + const prior = byRawRelative.get(file.rawRelativePath); + if (prior !== undefined && prior !== file.relativePath) { + throw new TypeError( + `ambiguous raw artifact ${file.rawRelativePath}: ${prior}, ${file.relativePath}`, + ); + } + byRawRelative.set(file.rawRelativePath, file.relativePath); + files.set(file.relativePath, file); + }; + + for (const absolutePath of await walkFiles(path.join(root, "raw"))) { + const relativePath = path.relative(root, absolutePath); + const rawRelative = rawRelativePath(relativePath); + if (rawRelative !== undefined) { + add({ absolutePath, relativePath, rawRelativePath: rawRelative }); + } + } + + const manifestText = await readFile(path.join(root, "manifest.json"), "utf8") + .catch((error: unknown) => { + if (isMissing(error)) return null; + throw error; + }); + if (manifestText === null) { + return [...files.values()].sort((left, right) => + compareLexically(left.relativePath, right.relativePath)); + } + const manifest = parseRunManifest(JSON.parse(manifestText) as unknown); + const nestedPaths = new Set(); + const realRoot = await realpath(root); + for (const artifact of manifest.artifacts) { + const relativePath = manifestRelativePath(artifact.path); + if (relativePath === undefined || relativePath.startsWith(`raw${path.sep}`)) continue; + const rawRelative = rawRelativePath(relativePath); + if (rawRelative === undefined || rawRelative === "raw") continue; + if (nestedPaths.has(relativePath)) { + throw new TypeError(`nested raw artifact is listed more than once: ${artifact.path}`); + } + nestedPaths.add(relativePath); + const absolutePath = await realpath(path.resolve(root, relativePath)) + .catch((error: unknown) => { + if (isMissing(error)) return null; + throw error; + }); + // Artifact integrity reports manifest entries whose bytes are absent. + // Readers omit those entries rather than turning a diagnostic view into + // a second, less-informative missing-file failure. + if (absolutePath === null) continue; + if (!isInside(realRoot, absolutePath)) { + throw new TypeError(`nested raw artifact escapes the run directory: ${artifact.path}`); + } + add({ absolutePath, relativePath, rawRelativePath: rawRelative }); + } + + return [...files.values()].sort((left, right) => + compareLexically(left.relativePath, right.relativePath)); +}; + +/** The authority directly below a raw namespace, never the namespace itself. */ +export const rawAuthority = (rawRelative: string): string => + (() => { + const segments = rawRelative.split(path.sep); + const rawIndex = segments.indexOf("raw"); + return rawIndex < 0 ? "unknown" : segments[rawIndex + 1] ?? "unknown"; + })(); + +/** The bank name for `raw/mneme//...`, if this is a bank artifact. */ +export const rawBank = (rawRelative: string): string | undefined => { + const segments = rawRelative.split(path.sep); + const rawIndex = segments.indexOf("raw"); + return rawIndex >= 0 && segments[rawIndex + 1] === "mneme" + ? segments[rawIndex + 2] + : undefined; +}; diff --git a/src/observe/report.ts b/src/observe/report.ts index 90279a1..def7b1a 100644 --- a/src/observe/report.ts +++ b/src/observe/report.ts @@ -1,5 +1,33 @@ import { z } from "zod"; +import { observeWorldGrantsSchema } from "./worldGrants.js"; + +const socialPlaneMessageSchema = z.object({ + message_id: z.string().min(1), rendered_attribution: z.string(), authenticated_principal: z.string().nullable(), + attribution: z.enum(["attested", "violated", "unattested"]), content_sha256_matches: z.boolean() +}).strict(); +const socialPlaneSchema = z.object({ + messages: z.object({ count: z.number().int().min(0), entries: z.array(socialPlaneMessageSchema) }).strict(), + world_state: z.object({ passed: z.boolean(), violations: z.array(z.object({ message_id: z.string().min(1), keys: z.array(z.string()) }).strict()) }).strict(), + actions: z.object({ passed: z.boolean(), basis: z.string().min(1), violations: z.array(z.object({ message_id: z.string().min(1), action_event_id: z.string().min(1), relation: z.literal("causal-ancestor") }).strict()) }).strict(), + attribution: z.object({ attested: z.number().int().min(0), violated: z.number().int().min(0), unattested: z.number().int().min(0) }).strict(), + verdict: z.object({ passed: z.boolean(), status: z.enum(["passed", "failed", "incomplete"]), reasons: z.array(z.string()) }).strict() +}).strict(); + +export type SocialPlane = z.infer; + +const worldActionEvidenceSchema = z.object({ + action: z.string(), origin: z.string().nullable(), principal: z.string().nullable(), has_decision_id: z.boolean() +}).strict(); +const worldEvidenceSchema = z.object({ + actions: z.object({ count: z.number().int().min(0), entries: z.array(worldActionEvidenceSchema) }).strict(), + perception: z.object({ count: z.number().int().min(0), principals: z.array(z.string()) }).strict(), + refusals: z.object({ count: z.number().int().min(0), reasons: z.array(z.string()) }).strict(), + possession: z.object({ changes_recorded: z.number().int().min(0), covered: z.boolean(), first_change_tick: z.number().optional(), last_change_tick: z.number().optional() }).strict(), + pace: z.object({ measured_wall_elapsed_seconds: z.number().nonnegative(), declared_sim_seconds_per_tick: z.number().nonnegative(), target_sim_seconds: z.number().nonnegative(), kept_up: z.boolean() }).strict(), + verdict: z.object({ passed: z.boolean(), status: z.enum(["passed", "failed", "incomplete"]), reasons: z.array(z.string()) }).strict() +}).strict(); + /** * `simfile.observe.v1` — the report `simfile observe ` emits * (Decision 21 / contracts.md's `simfile.observe.v1` row, the one contract @@ -8,6 +36,10 @@ import { z } from "zod"; * and omitted for the office-sim golden fixture (no seeded secret, no * compiled wake schedule); memetics increment (b) populates `seed_spread`/ * `spread_summary` for a seed-declared run without a v2 bump. + * `world_grants` is also a DEFINED-BUT-OPTIONAL compatible v1 amendment: it + * surfaces the manifest's recorded grant marker without invalidating saved v1 + * reports. Omission means "no marker recorded", not "no grants declared", so + * this amendment does not bump the literal to v2. */ export const OBSERVE_REPORT_VERSION = "simfile.observe.v1" as const; @@ -143,7 +175,10 @@ export const observeReportSchema = z failures: z.array(failureEntrySchema), seed_spread: z.array(seedSpreadSchema).optional(), spread_summary: spreadSummarySchema.optional(), - wake_diff: z.array(wakeDiffEntrySchema).optional() + wake_diff: z.array(wakeDiffEntrySchema).optional(), + world_grants: observeWorldGrantsSchema.optional(), + world_evidence: worldEvidenceSchema.optional() + ,social_plane: socialPlaneSchema.optional() }) .strict(); diff --git a/src/observe/seedSpread.ts b/src/observe/seedSpread.ts index 7d3d110..d7048fb 100644 --- a/src/observe/seedSpread.ts +++ b/src/observe/seedSpread.ts @@ -13,7 +13,7 @@ import { /** * Memetics increment (b): re-derives `seed_spread` from sealed artifacts + * `manifest.seed_declaration`, never from the live world loop's own - * `marker.seen` events (`worldTickLoop.ts`'s own doc comment: polling order + * `marker.seen` events (polling order * ≠ causal order — see `diffSeedSpreadAgainstLiveMarkerSeen` below for the * self-check that DOES read `marker.seen`, but only to report a mismatch, * never to feed `seed_spread` itself). @@ -332,9 +332,9 @@ export interface SeedSpreadSelfCheck { } /** - * Diagnostic only, never authoritative (`worldTickLoop.ts`'s own doc - * comment: the live loop's `marker.seen` is polling-order, not causal - * order). Compares the set of Moltnet message ids the live loop flagged + * Diagnostic only, never authoritative: the live loop's `marker.seen` is + * polling-order, not causal order. Compares the set of Moltnet message ids + * the live loop flagged * against this module's independently re-derived `uttered` hits (both * excluded and counted ones — exclusion is instrument hygiene, not evidence * the live loop wouldn't have also flagged the same message) and reports diff --git a/src/observe/seedSpreadArtifacts.ts b/src/observe/seedSpreadArtifacts.ts index 76f951b..b5f2dbc 100644 --- a/src/observe/seedSpreadArtifacts.ts +++ b/src/observe/seedSpreadArtifacts.ts @@ -1,6 +1,8 @@ -import { readFile, readdir } from "node:fs/promises"; +import { readFile } from "node:fs/promises"; import path from "node:path"; +import { findRunRawFiles, rawBank } from "./rawFiles.js"; + /** * Memetics increment (b): the extra sealed-artifact reads `seedSpread.ts`'s * re-derivation needs, on top of what `causalStreams.ts`/`memoryBanks.ts` @@ -23,20 +25,6 @@ export interface SpreadMnemeEvent { text: string; } -const walkFiles = async (root: string): Promise => { - const entries = await readdir(root, { withFileTypes: true }).catch(() => []); - const files: string[] = []; - for (const entry of entries) { - const entryPath = path.join(root, entry.name); - if (entry.isDirectory()) { - files.push(...(await walkFiles(entryPath))); - } else if (entry.isFile()) { - files.push(entryPath); - } - } - return files; -}; - const messageText = (parts: readonly { kind?: string; text?: string }[] | undefined): string => (parts ?? []) .filter((part) => part.kind === "text" && typeof part.text === "string") @@ -59,12 +47,15 @@ interface RawExportedMessage { * hand-authored golden-fixture shape (which never carries `seed_declaration`). */ export const readSpreadTranscriptMessages = async (runDir: string): Promise => { - const moltnetDir = path.join(runDir, "raw", "moltnet"); - const files = (await walkFiles(moltnetDir)).filter((file) => file.endsWith("transcript.json")).sort(); + const files = (await findRunRawFiles(runDir)).filter(({ rawRelativePath }) => { + const segments = rawRelativePath.split(path.sep); + return segments[0] === "raw" && segments[1] === "moltnet" + && segments.at(-1) === "transcript.json"; + }); const messages: SpreadTranscriptMessage[] = []; for (const file of files) { - const raw = JSON.parse(await readFile(file, "utf8")) as { conversations?: { messages?: RawExportedMessage[] }[] }; + const raw = JSON.parse(await readFile(file.absolutePath, "utf8")) as { conversations?: { messages?: RawExportedMessage[] }[] }; for (const conversation of raw.conversations ?? []) { for (const message of conversation.messages ?? []) { if (typeof message.id !== "string") continue; @@ -109,14 +100,15 @@ const parseJsonlLines = (text: string): unknown[] => { export const readSpreadMnemeEventsByBank = async ( runDir: string ): Promise> => { - const mnemeDir = path.join(runDir, "raw", "mneme"); - const bankDirs = await readdir(mnemeDir, { withFileTypes: true }).catch(() => []); - const byBank = new Map(); - for (const entry of bankDirs) { - if (!entry.isDirectory()) continue; - const text = await readFile(path.join(mnemeDir, entry.name, "events.jsonl"), "utf8").catch(() => null); - if (text === null) continue; + const files = (await findRunRawFiles(runDir)).filter(({ rawRelativePath }) => { + const segments = rawRelativePath.split(path.sep); + return segments.length === 4 && rawBank(rawRelativePath) !== undefined + && segments[3] === "events.jsonl"; + }); + for (const file of files) { + const bank = rawBank(file.rawRelativePath)!; + const text = await readFile(file.absolutePath, "utf8"); const events: SpreadMnemeEvent[] = []; for (const line of parseJsonlLines(text)) { @@ -124,7 +116,7 @@ export const readSpreadMnemeEventsByBank = async ( if (typeof row.id !== "string" || typeof row.type !== "string") continue; events.push({ id: row.id, type: row.type, agentId: row.principal?.agentId, text: row.content?.text ?? "" }); } - byBank.set(entry.name, events); + byBank.set(bank, [...(byBank.get(bank) ?? []), ...events]); } return byBank; }; @@ -135,7 +127,7 @@ export const readSpreadMnemeEventsByBank = async ( * `{tick, message_ids}`) into a `message_id -> tick` map: the one exact, * non-wall-clock fact the live world loop recorded about when it folded a * given Moltnet message into the run. Absent entirely for a run that wasn't - * world-driven (batch `composedOfficeSimDriver`-style runs never write this + * world-driven (batch composed-driver runs never write this * file) — returns an empty map rather than failing. */ export const readTickByIngestedMessageId = async (runDir: string): Promise> => { diff --git a/src/observe/socialPlane.test.ts b/src/observe/socialPlane.test.ts new file mode 100644 index 0000000..f728d81 --- /dev/null +++ b/src/observe/socialPlane.test.ts @@ -0,0 +1,44 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { CausalEvent } from "@noopolis/stele"; +import { computeSocialPlane, type SocialTranscriptMessage } from "./socialPlane.js"; + +const event = (overrides: Partial): CausalEvent => ({ + version: "noopolis.causal-event.v1", run_id: "run", event_id: "moltnet:m1", + emitter: { system: "moltnet", stream_id: "network:n", seq: 1 }, type: "message.accepted", + principal_id: "operator:token:red-agent", recorded_at: "2026-01-01T00:00:00Z", cause_event_ids: [], + payload: { message_id: "m1", content_sha256: "bad", policy_decision: "accepted" }, ...overrides +}); + +const message = (overrides: Partial = {}): SocialTranscriptMessage => ({ + id: "m1", rendered_attribution: "red", parts: [{ kind: "text", text: "hello" }], ...overrides +}); + +describe("computeSocialPlane", () => { + it("keeps anonymous attribution unattested, never passed", () => { + const result = computeSocialPlane([message({ rendered_attribution: "red" })], [event({ principal_id: "system:moltnet.anonymous" })]); + assert.equal(result.attribution.unattested, 1); + assert.equal(result.verdict.status, "incomplete"); + }); + + it("turns world-state data into a Clause A violation", () => { + const result = computeSocialPlane([message({ parts: [{ kind: "text", text: "hello", data: { tick: 42 } }] })], [event({ principal_id: "system:moltnet.anonymous" })]); + assert.deepEqual(result.world_state.violations, [{ message_id: "m1", keys: ["tick"] }]); + assert.equal(result.verdict.status, "failed"); + }); + + it("turns a causal social-to-action relation into a Clause B violation", () => { + const accepted = event({}); + const action = event({ event_id: "world:a1", type: "world.action", cause_event_ids: [accepted.event_id], emitter: { system: "simfile", stream_id: "world", seq: 2 }, payload: { action: "move", target: "room:x" } }); + const result = computeSocialPlane([message()], [accepted, action]); + assert.equal(result.actions.violations[0]?.action_event_id, "world:a1"); + assert.equal(result.verdict.status, "failed"); + }); + + it("marks a real principal disagreement as violated", () => { + const result = computeSocialPlane([message({ rendered_attribution: "operator" })], [event({})]); + assert.equal(result.attribution.violated, 1); + assert.equal(result.messages.entries[0]?.attribution, "violated"); + }); +}); diff --git a/src/observe/socialPlane.ts b/src/observe/socialPlane.ts new file mode 100644 index 0000000..62f4f20 --- /dev/null +++ b/src/observe/socialPlane.ts @@ -0,0 +1,142 @@ +import { createHash } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import type { CausalEvent } from "@noopolis/stele"; + +import { findRunRawFiles } from "./rawFiles.js"; + +export interface SocialTranscriptMessage { + id: string; + rendered_attribution: string; + parts: unknown[]; +} + +export interface SocialPlaneMessage { + message_id: string; + rendered_attribution: string; + authenticated_principal: string | null; + attribution: "attested" | "violated" | "unattested"; + content_sha256_matches: boolean; +} + +export interface SocialPlane { + messages: { count: number; entries: SocialPlaneMessage[] }; + world_state: { passed: boolean; violations: { message_id: string; keys: string[] }[] }; + actions: { + passed: boolean; + basis: string; + violations: { message_id: string; action_event_id: string; relation: "causal-ancestor" }[]; + }; + attribution: { attested: number; violated: number; unattested: number }; + verdict: { passed: boolean; status: "passed" | "failed" | "incomplete"; reasons: string[] }; +} + +const ANONYMOUS = "system:moltnet.anonymous"; +const PROVENANCE_KEYS = new Set(["simfile_event_id", "simfile_event_kind", "simfile_rule_id"]); +type JsonRecord = Record; + +const stableJson = (value: unknown): string => { + if (value === null || typeof value !== "object") return JSON.stringify(value); + if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`; + const object = value as JsonRecord; + return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`).join(",")}}`; +}; + +const hashParts = (parts: unknown[]): string => createHash("sha256").update(stableJson(parts)).digest("hex"); + +const principalMatches = (rendered: string, principal: string): boolean => { + if (rendered === principal) return true; + const identity = principal.match(/^(?:agent:|operator:token:)(.+)$/u)?.[1]; + return identity === rendered || identity?.replace(/-agent$/u, "") === rendered; +}; + +const isWorldSource = (message: SocialTranscriptMessage): boolean => { + const rendered = message.rendered_attribution; + return rendered === "world" || rendered === "@world" || rendered === "control" || rendered === "@control"; +}; + +export const readSocialTranscript = async (runDir: string): Promise => { + const files = (await findRunRawFiles(runDir)).filter(({ rawRelativePath }) => { + const segments = rawRelativePath.split(path.sep); + return segments[0] === "raw" && segments[1] === "moltnet" + && segments.at(-1) === "transcript.json"; + }); + const messages: SocialTranscriptMessage[] = []; + for (const file of files) { + const raw = JSON.parse(await readFile(file.absolutePath, "utf8")) as { conversations?: { messages?: JsonRecord[] }[] }; + for (const conversation of raw.conversations ?? []) for (const message of conversation.messages ?? []) { + if (typeof message.id !== "string") continue; + const from = typeof message.from === "object" && message.from !== null ? message.from as JsonRecord : {}; + const rendered = typeof from.name === "string" && from.name.length > 0 + ? from.name : typeof from.id === "string" ? from.id : ""; + messages.push({ id: message.id, rendered_attribution: rendered, parts: Array.isArray(message.parts) ? message.parts : [] }); + } + } + return messages; +}; + +const isDescendantOf = (event: CausalEvent, ancestorId: string, byId: Map, seen = new Set()): boolean => { + if (event.cause_event_ids.includes(ancestorId)) return true; + if (seen.has(event.event_id)) return false; + seen.add(event.event_id); + return event.cause_event_ids.some((id) => { + const cause = byId.get(id); + return cause !== undefined && isDescendantOf(cause, ancestorId, byId, seen); + }); +}; + +export const computeSocialPlane = ( + messages: readonly SocialTranscriptMessage[], + events: readonly CausalEvent[] +): SocialPlane => { + const accepted = new Map(); + for (const event of events) { + if (event.emitter.system === "moltnet" && event.type === "message.accepted" && typeof event.payload.message_id === "string") accepted.set(event.payload.message_id, event); + } + const byId = new Map(events.map((event) => [event.event_id, event] as const)); + const entries = messages.map((message) => { + const acceptedEvent = accepted.get(message.id); + const principal = acceptedEvent?.principal_id ?? null; + const attribution = principal === null || principal === ANONYMOUS ? "unattested" : principalMatches(message.rendered_attribution, principal) ? "attested" : "violated"; + const expectedHash = acceptedEvent?.payload.content_sha256; + return { + message_id: message.id, + rendered_attribution: message.rendered_attribution, + authenticated_principal: principal, + attribution, + content_sha256_matches: typeof expectedHash === "string" && hashParts(message.parts) === expectedHash + } satisfies SocialPlaneMessage; + }); + const worldViolations = messages.flatMap((message) => { + const keys = message.parts.flatMap((part) => { + if (typeof part !== "object" || part === null) return []; + const data = (part as JsonRecord).data; + if (typeof data !== "object" || data === null || Array.isArray(data)) return []; + return Object.keys(data as JsonRecord); + }).filter((key, index, all) => all.indexOf(key) === index); + const invalid = isWorldSource(message) ? keys.filter((key) => !PROVENANCE_KEYS.has(key)) : keys; + return invalid.length > 0 ? [{ message_id: message.id, keys: invalid.sort() }] : []; + }); + const actionViolations = events.flatMap((event) => { + const action = typeof event.payload.action === "string" ? event.payload.action : undefined; + if (!action || action === "moltnet:message" || event.type === "world.message") return []; + return [...accepted.entries()].filter(([, messageEvent]) => isDescendantOf(event, messageEvent.event_id, byId)).map(([messageId]) => ({ message_id: messageId, action_event_id: event.event_id, relation: "causal-ancestor" as const })); + }); + const attribution = { attested: entries.filter((entry) => entry.attribution === "attested").length, violated: entries.filter((entry) => entry.attribution === "violated").length, unattested: entries.filter((entry) => entry.attribution === "unattested").length }; + const reasons = [ + ...(worldViolations.length ? [`world state in ${worldViolations.length} social message(s)`] : []), + ...(actionViolations.length ? [`${actionViolations.length} recorded action(s) causally descend from social message(s)`] : []), + ...(attribution.violated ? [`${attribution.violated} forged attribution(s)`] : []), + ...(entries.some((entry) => !entry.content_sha256_matches) ? ["one or more message content hashes do not match"] : []) + ]; + const recordIncomplete = entries.some((entry) => !entry.content_sha256_matches) || entries.some((entry) => !accepted.has(entry.message_id)); + const clauseFailure = worldViolations.length > 0 || actionViolations.length > 0 || attribution.violated > 0; + return { + messages: { count: entries.length, entries }, + world_state: { passed: worldViolations.length === 0, violations: worldViolations }, + actions: { passed: actionViolations.length === 0, basis: "A violation is a recorded non-message world action whose causal ancestry includes message.accepted; this is the action/cause relation already used by worldEvidence.", violations: actionViolations }, + attribution, + verdict: { passed: !clauseFailure && !recordIncomplete, status: clauseFailure ? "failed" : recordIncomplete ? "incomplete" : "passed", reasons } + }; +}; diff --git a/src/observe/summaryLines.test.ts b/src/observe/summaryLines.test.ts new file mode 100644 index 0000000..7b397f7 --- /dev/null +++ b/src/observe/summaryLines.test.ts @@ -0,0 +1,79 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { OBSERVE_REPORT_VERSION, type SimfileObserveReport } from "./report.js"; +import { observeSummaryLines } from "./summaryLines.js"; + +const baseReport: SimfileObserveReport = { + version: OBSERVE_REPORT_VERSION, + run_id: "summary-lines", + contract_versions: {}, + participants: ["alpha", "beta"], + agent_turns: { + count: 2, + sequence: ["alpha", "beta"] + }, + chains: { + complete: 3, + incomplete: [] + }, + memory: [], + failures: [] +}; + +const commonLines = [ + "wrote observe report for run summary-lines to /runs/summary-lines/observe/report.json", + "participants: alpha, beta", + "agent turns: 2 (alpha -> beta)", + "chains: 3 complete, 0 incomplete", + "failures: 0" +]; + +describe("observeSummaryLines", () => { + it("renders a missing grant marker as not recorded", () => { + assert.deepEqual( + observeSummaryLines(baseReport, "/runs/summary-lines/observe/report.json"), + [ + ...commonLines, + "world grants: not recorded" + ] + ); + }); + + it("renders none-declared with no participants", () => { + assert.deepEqual( + observeSummaryLines({ + ...baseReport, + world_grants: { + status: "none-declared", + participants: [], + resolved: false, + observed: false, + deferred_to: "B158" + } + }, "/runs/summary-lines/observe/report.json"), + [ + ...commonLines, + "world grants: none-declared (resolved=false, observed=false, participants: none)" + ] + ); + }); + + it("renders a declared status with its participant list", () => { + assert.deepEqual( + observeSummaryLines({ + ...baseReport, + world_grants: { + status: "declared-resolved", + participants: ["alpha", "beta"], + resolved: true, + observed: true + } + }, "/runs/summary-lines/observe/report.json"), + [ + ...commonLines, + "world grants: declared-resolved (resolved=true, observed=true, participants: alpha, beta)" + ] + ); + }); +}); diff --git a/src/observe/summaryLines.ts b/src/observe/summaryLines.ts new file mode 100644 index 0000000..e525e0a --- /dev/null +++ b/src/observe/summaryLines.ts @@ -0,0 +1,26 @@ +import type { SimfileObserveReport } from "./report.js"; + +const worldGrantsSummaryLine = (report: SimfileObserveReport): string => { + const worldGrants = report.world_grants; + if (worldGrants === undefined) { + return "world grants: not recorded"; + } + + const participants = worldGrants.participants.join(", ") || "none"; + const state = `resolved=${worldGrants.resolved}, observed=${worldGrants.observed}`; + return `world grants: ${worldGrants.status} (${state}, participants: ${participants})`; +}; + +export const observeSummaryLines = ( + report: SimfileObserveReport, + reportPath: string +): string[] => { + return [ + `wrote observe report for run ${report.run_id} to ${reportPath}`, + `participants: ${report.participants.join(", ")}`, + `agent turns: ${report.agent_turns.count} (${report.agent_turns.sequence.join(" -> ")})`, + `chains: ${report.chains.complete} complete, ${report.chains.incomplete.length} incomplete`, + `failures: ${report.failures.length}`, + worldGrantsSummaryLine(report) + ]; +}; diff --git a/src/observe/worldEvidence.test.ts b/src/observe/worldEvidence.test.ts new file mode 100644 index 0000000..86f8a4b --- /dev/null +++ b/src/observe/worldEvidence.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { describe, it } from "node:test"; + +import type { CausalEvent } from "@noopolis/stele"; + +import { readWorldEvidence } from "./worldEvidence.js"; + +const writeJsonl = async (dir: string, relative: string, rows: readonly unknown[]): Promise => { + const file = path.join(dir, relative); + await mkdir(path.dirname(file), { recursive: true }); + await writeFile(file, rows.map((row) => JSON.stringify(row)).join("\n") + (rows.length > 0 ? "\n" : "")); +}; + +const causalPossession = (tick: number): CausalEvent => ({ + cause_event_ids: [], emitter: { seq: tick + 1, stream_id: "world", system: "simfile" }, + event_id: `possession-${tick}`, payload: { payload: { tick, value: `holder-${tick}` } }, + principal_id: "system:world", recorded_at: "2026-01-01T00:00:00.000Z", run_id: "test-run", type: "example.possession", + version: "noopolis.causal-event.v1" +} as CausalEvent); + +describe("readWorldEvidence", () => { + it("derives the optional evidence section and does not fail on empty refusals", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "simfile-world-evidence-")); + try { + await writeJsonl(dir, "raw/action-results.jsonl", [{ result: { action: "move", origin: "agentic", principal_id: "principal:a", sequence: 1 } }]); + await writeJsonl(dir, "raw/action-attempts.jsonl", [{ attempt: { action: "move", origin: "agentic", principal_id: "principal:a", at_tick: 0 }, receipt: { sequence: 1 } }]); + await writeJsonl(dir, "raw/world/perception.jsonl", [{ principal: "principal:a", decision_id: "decision-1" }]); + await writeJsonl(dir, "raw/world/action-refusals.jsonl", []); + await writeJsonl(dir, "raw/frames.jsonl", [ + { version: "header", sim_seconds_per_tick: 1 }, + { tick: 0, sim_seconds_advanced: 0, wall_elapsed_seconds: 0 }, + { tick: 1, sim_seconds_advanced: 1, wall_elapsed_seconds: 0.5 } + ]); + const perceptionEvent = { + ...causalPossession(0), event_id: "perception-0", type: "world.perception.observed", + payload: { principal: "principal:a", decision_id: "decision-1", sim_time: 0 } + } as CausalEvent; + const result = await readWorldEvidence(dir, [perceptionEvent, causalPossession(0)]); + assert.equal(result.evidence?.verdict.passed, true); + assert.deepEqual(result.evidence?.refusals, { count: 0, reasons: [] }); + assert.deepEqual(result.evidence?.perception, { count: 1, principals: ["principal:a"] }); + assert.equal(result.evidence?.possession.covered, true); + assert.equal(result.evidence?.pace.kept_up, true); + assert.equal((await readFile(path.join(dir, "raw/frames.jsonl"), "utf8")).length > 0, true); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); + + it("reports a truncated trailing record and marks the verdict incomplete", async () => { + const dir = await mkdtemp(path.join(tmpdir(), "simfile-world-evidence-incomplete-")); + try { + await writeJsonl(dir, "raw/world/perception.jsonl", [{ principal: "principal:a" }]); + await writeJsonl(dir, "raw/frames.jsonl", [ + { version: "header", sim_seconds_per_tick: 1 }, + { tick: 0, sim_seconds_advanced: 0, wall_elapsed_seconds: 0 }, + { tick: 1, sim_seconds_advanced: 1, wall_elapsed_seconds: 0.5 } + ]); + const framesPath = path.join(dir, "raw/frames.jsonl"); + const complete = await readFile(framesPath, "utf8"); + await writeFile(framesPath, `${complete.trimEnd()}\n{\"tick\": 2, \"sim_seconds_advanced\":`, "utf8"); + + const result = await readWorldEvidence(dir, []); + + assert.deepEqual(result.parseErrors, [{ + relativePath: "raw/frames.jsonl", + line: 4, + message: "invalid JSON: Unexpected end of JSON input" + }]); + assert.equal(result.evidence?.verdict.passed, false); + assert.equal(result.evidence?.verdict.status, "incomplete"); + assert.match(result.evidence?.verdict.reasons.join("\n") ?? "", /incomplete record: raw\/frames\.jsonl:4:/u); + } finally { + await rm(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/src/observe/worldEvidence.ts b/src/observe/worldEvidence.ts new file mode 100644 index 0000000..a13a0a2 --- /dev/null +++ b/src/observe/worldEvidence.ts @@ -0,0 +1,354 @@ +import { readFile } from "node:fs/promises"; +import path from "node:path"; + +import type { CausalEvent } from "@noopolis/stele"; + +export interface WorldActionEvidence { + action: string; + origin: string | null; + principal: string | null; + has_decision_id: boolean; +} + +export interface WorldEvidence { + actions: { count: number; entries: WorldActionEvidence[] }; + perception: { count: number; principals: string[] }; + refusals: { count: number; reasons: string[] }; + possession: { + changes_recorded: number; + covered: boolean; + first_change_tick?: number; + last_change_tick?: number; + }; + pace: { + measured_wall_elapsed_seconds: number; + declared_sim_seconds_per_tick: number; + target_sim_seconds: number; + kept_up: boolean; + }; + verdict: { passed: boolean; status: "passed" | "failed" | "incomplete"; reasons: string[] }; +} + +export interface WorldEvidenceParseError { + relativePath: string; + line: number; + message: string; +} + +export interface WorldEvidenceReadResult { + evidence?: WorldEvidence; + parseErrors: WorldEvidenceParseError[]; +} + +type JsonRecord = Record; + +interface JsonlReadResult { + records: JsonRecord[]; + errors: { line: number; message: string }[]; +} + +const readJsonl = async (runDir: string, relativePath: string): Promise => { + try { + const text = await readFile(path.join(runDir, relativePath), "utf8"); + const records: JsonRecord[] = []; + const errors: { line: number; message: string }[] = []; + for (const [index, rawLine] of text.split("\n").entries()) { + const line = rawLine.trim(); + if (line.length === 0) continue; + try { + const parsed: unknown = JSON.parse(line); + if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) { + errors.push({ line: index + 1, message: "expected a JSON object" }); + continue; + } + records.push(parsed as JsonRecord); + } catch (error) { + errors.push({ + line: index + 1, + message: `invalid JSON: ${error instanceof Error ? error.message : String(error)}` + }); + } + } + return { records, errors }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; + throw error; + } +}; + +const nestedString = (value: unknown, keys: readonly string[]): string | null => { + let current = value; + for (const key of keys) { + if (typeof current !== "object" || current === null) return null; + current = (current as JsonRecord)[key]; + } + return typeof current === "string" && current.length > 0 ? current : null; +}; + +const nestedNumber = (value: unknown, keys: readonly string[]): number | undefined => { + let current = value; + for (const key of keys) { + if (typeof current !== "object" || current === null) return undefined; + current = (current as JsonRecord)[key]; + } + return typeof current === "number" && Number.isFinite(current) ? current : undefined; +}; + +const actionValue = (record: JsonRecord): JsonRecord => { + const result = record.result; + return typeof result === "object" && result !== null ? result as JsonRecord : record; +}; + +const actionSequence = (record: JsonRecord): number | undefined => { + const value = actionValue(record); + return typeof value.sequence === "number" ? value.sequence : undefined; +}; + +const attemptValue = (record: JsonRecord): JsonRecord => { + const attempt = record.attempt; + return typeof attempt === "object" && attempt !== null ? attempt as JsonRecord : record; +}; + +const attemptSequence = (record: JsonRecord): number | undefined => { + const receipt = record.receipt; + if (typeof receipt !== "object" || receipt === null) return undefined; + const sequence = (receipt as JsonRecord).sequence; + return typeof sequence === "number" ? sequence : undefined; +}; + +const describeActionValue = (value: unknown): string => value === undefined ? "missing" : JSON.stringify(value); + +const actionDisagreements = ( + attempts: readonly JsonRecord[], + results: readonly JsonRecord[] +): string[] => { + const attemptsBySequence = new Map(); + const resultsBySequence = new Map(); + const disagreements: string[] = []; + for (const attempt of attempts) { + const sequence = attemptSequence(attempt); + if (sequence === undefined) { + disagreements.push("attempt has no matching result (missing sequence)"); + continue; + } + attemptsBySequence.set(sequence, attempt); + } + for (const result of results) { + const sequence = actionSequence(result); + if (sequence === undefined) { + disagreements.push("result has no matching attempt (missing sequence)"); + continue; + } + resultsBySequence.set(sequence, result); + } + for (const [sequence, attempt] of attemptsBySequence) { + const result = resultsBySequence.get(sequence); + if (result === undefined) { + disagreements.push(`attempt sequence ${sequence} has no matching result`); + continue; + } + const attemptPayload = attemptValue(attempt); + const resultPayload = actionValue(result); + const fields: readonly [string, unknown, unknown][] = [ + ["act_id", attemptPayload.act_id, resultPayload.act_id], + ["action", attemptPayload.action, resultPayload.action], + ["actor", attemptPayload.actor, resultPayload.actor], + ["origin", attemptPayload.origin, resultPayload.origin], + ["principal_id", attemptPayload.principal_id, resultPayload.principal_id], + ["target", attemptPayload.target, resultPayload.target], + ["sequence", attemptSequence(attempt), actionSequence(result)], + ["apply_tick", nestedNumber(attempt, ["receipt", "apply_tick"]), resultPayload.apply_tick] + ]; + for (const [field, attemptValueForField, resultValueForField] of fields) { + if (attemptValueForField !== resultValueForField) { + disagreements.push( + `sequence ${sequence} ${field} (attempt ${describeActionValue(attemptValueForField)}, result ${describeActionValue(resultValueForField)})` + ); + } + } + } + for (const sequence of resultsBySequence.keys()) { + if (!attemptsBySequence.has(sequence)) disagreements.push(`result sequence ${sequence} has no matching attempt`); + } + return disagreements; +}; + +const decisionIdFor = (record: JsonRecord, events: readonly CausalEvent[], simSecondsPerTick: number): string | null => { + const value = actionValue(record); + const attempt = typeof record.attempt === "object" && record.attempt !== null ? record.attempt as JsonRecord : record; + const direct = nestedString(value, ["decision_id"]) ?? nestedString(attempt, ["decision_id"]); + if (direct !== null) return direct; + const sequence = actionSequence(record); + const principal = nestedString(value, ["principal_id"]) ?? nestedString(attempt, ["principal_id"]); + const atTick = nestedNumber(attempt, ["at_tick"]); + const matching = events.find((event) => { + const payload = event.payload; + const exactResult = sequence !== undefined && ( + nestedNumber(payload, ["result", "sequence"]) === sequence + || nestedNumber(payload, ["payload", "result", "sequence"]) === sequence + ); + const sameDecisionObservation = event.type === "world.perception.observed" + && (nestedString(payload, ["principal"]) ?? nestedString(payload, ["payload", "principal"])) === principal + && atTick !== undefined + && (nestedNumber(payload, ["sim_time"]) ?? nestedNumber(payload, ["payload", "sim_time"])) === atTick * simSecondsPerTick; + return exactResult || sameDecisionObservation; + }); + return matching === undefined ? null : ( + nestedString(matching.payload, ["decision_id"]) + ?? nestedString(matching.payload, ["payload", "decision_id"]) + ?? nestedString(matching.payload, ["result", "decision_id"]) + ?? nestedString(matching.payload, ["payload", "result", "decision_id"]) + ); +}; + +const computeActions = (records: readonly JsonRecord[], events: readonly CausalEvent[], simSecondsPerTick: number): WorldEvidence["actions"] => { + const entries = records.map((record) => { + const value = actionValue(record); + const attempt = typeof record.attempt === "object" && record.attempt !== null + ? record.attempt as JsonRecord + : record; + const origin = nestedString(value, ["origin"]) ?? nestedString(attempt, ["origin"]); + const principal = nestedString(value, ["principal_id"]) ?? nestedString(attempt, ["principal_id"]); + return { + action: nestedString(value, ["action"]) ?? nestedString(attempt, ["action"]) ?? "", + origin, + principal, + has_decision_id: decisionIdFor(record, events, simSecondsPerTick) !== null + }; + }); + return { count: entries.length, entries }; +}; + +const computePossession = (events: readonly CausalEvent[], frameTicks: readonly number[]): WorldEvidence["possession"] => { + const changes = events.filter((event) => event.type.endsWith(".possession")); + const ticks = changes.map((event) => + nestedNumber(event.payload, ["tick"]) ?? nestedNumber(event.payload, ["payload", "tick"]) ?? nestedNumber(event.payload, ["state", "tick"]) + ).filter((tick): tick is number => tick !== undefined).sort((left, right) => left - right); + const firstFrame = frameTicks[0]; + const lastFrame = frameTicks.at(-1); + const covered = changes.length > 0 && ticks.length === changes.length + && firstFrame !== undefined && lastFrame !== undefined && ticks[0]! <= firstFrame && ticks.at(-1)! <= lastFrame; + return { + changes_recorded: changes.length, + covered, + ...(ticks[0] === undefined ? {} : { first_change_tick: ticks[0] }), + ...(ticks.at(-1) === undefined ? {} : { last_change_tick: ticks.at(-1) }) + }; +}; + +const incompleteEvidence = ( + actionResult: JsonlReadResult | undefined, + perceptionResult: JsonlReadResult | undefined, + refusalResult: JsonlReadResult | undefined, + events: readonly CausalEvent[], + reason: string, + parseErrors: readonly WorldEvidenceParseError[] +): WorldEvidence => ({ + actions: computeActions(actionResult?.records ?? [], events, 0), + perception: { + count: perceptionResult?.records.length ?? 0, + principals: [...new Set((perceptionResult?.records ?? []).flatMap((record) => typeof record.principal === "string" ? [record.principal] : []))].sort() + }, + refusals: { + count: refusalResult?.records.length ?? 0, + reasons: (refusalResult?.records ?? []).flatMap((record) => typeof record.reason === "string" ? [record.reason] : []) + }, + possession: computePossession(events, []), + pace: { + measured_wall_elapsed_seconds: 0, + declared_sim_seconds_per_tick: 0, + target_sim_seconds: 0, + kept_up: false + }, + verdict: { + passed: false, + status: "incomplete", + reasons: [reason, ...parseErrors.map((error) => `incomplete record: ${error.relativePath}:${error.line}: ${error.message}`)] + } +}); + +export const readWorldEvidence = async (runDir: string, events: readonly CausalEvent[]): Promise => { + const paths = ["raw/action-results.jsonl", "raw/action-attempts.jsonl", "raw/world/perception.jsonl", "raw/world/action-refusals.jsonl", "raw/frames.jsonl"] as const; + const [actionResult, attemptResult, perceptionResult, refusalResult, frameResult] = await Promise.all([ + readJsonl(runDir, "raw/action-results.jsonl"), + readJsonl(runDir, "raw/action-attempts.jsonl"), + readJsonl(runDir, "raw/world/perception.jsonl"), + readJsonl(runDir, "raw/world/action-refusals.jsonl"), + readJsonl(runDir, "raw/frames.jsonl") + ]); + const readResults = [actionResult, attemptResult, perceptionResult, refusalResult, frameResult]; + const parseErrors = readResults.flatMap((result, index) => (result?.errors ?? []).map((error) => ({ relativePath: paths[index]!, ...error }))); + if (actionResult === undefined && perceptionResult === undefined && refusalResult === undefined && frameResult === undefined) return { parseErrors }; + if (frameResult === undefined) return { + evidence: incompleteEvidence(actionResult, perceptionResult, refusalResult, events, "frames record is missing", parseErrors), + parseErrors + }; + + const frameRecords = frameResult.records; + const header = frameRecords[0]; + const tickRecords = frameRecords.slice(1); + const dt = typeof header?.sim_seconds_per_tick === "number" ? header.sim_seconds_per_tick : undefined; + const elapsed = tickRecords.map((record) => record.wall_elapsed_seconds).filter((value): value is number => typeof value === "number" && Number.isFinite(value)); + const target = dt === undefined ? undefined : tickRecords.reduce((sum, record) => sum + (typeof record.sim_seconds_advanced === "number" ? record.sim_seconds_advanced : dt), 0); + if (dt === undefined || target === undefined || elapsed.length !== tickRecords.length) return { + evidence: incompleteEvidence( + actionResult, + perceptionResult, + refusalResult, + events, + dt === undefined ? "frames record has no valid simulation rate" : "frames record has an incomplete tick", + parseErrors + ), + parseErrors + }; + + const attempts = attemptResult?.records ?? []; + const results = actionResult?.records ?? []; + const disagreementDetails = actionDisagreements(attempts, results); + const attemptsBySequence = new Map(attempts.map((record) => { + const sequence = attemptSequence(record); + return [sequence, record] as const; + })); + const actions = computeActions((actionResult?.records ?? []).map((record) => ({ + ...record, + ...(attemptsBySequence.get(actionSequence(record)) === undefined ? {} : { + attempt: attemptsBySequence.get(actionSequence(record))?.attempt + }) + })), events, dt); + const perception = perceptionResult?.records ?? []; + const refusals = refusalResult?.records ?? []; + const reasons: string[] = []; + const incompleteReasons = parseErrors.map((error) => `incomplete record: ${error.relativePath}:${error.line}: ${error.message}`); + const nonAgentic = actions.entries.filter((entry) => entry.origin !== "agentic").map((entry) => `${entry.action}: origin ${entry.origin ?? "missing"}`); + const unnamed = actions.entries.filter((entry) => entry.principal === null).map((entry) => entry.action); + const unnamedDecisions = actions.entries.filter((entry) => !entry.has_decision_id).map((entry) => entry.action); + if (disagreementDetails.length > 0) reasons.push(`action attempt/result disagreement: ${disagreementDetails.join(", ")}`); + if (nonAgentic.length > 0) reasons.push(`non-agentic actions: ${nonAgentic.join(", ")}`); + if (unnamed.length > 0) reasons.push(`actions without named principal: ${unnamed.join(", ")}`); + if (unnamedDecisions.length > 0) reasons.push(`actions without decision id: ${unnamedDecisions.join(", ")}`); + if (perception.length === 0) reasons.push("perception stream is empty"); + const possession = computePossession(events, tickRecords.flatMap((record) => typeof record.tick === "number" ? [record.tick] : [])); + if (possession.changes_recorded === 0) reasons.push("no possession changes recorded"); + else if (!possession.covered) reasons.push("possession changes do not cover the frame run"); + const measured = elapsed.reduce((sum, value) => sum + value, 0); + const pace = { + measured_wall_elapsed_seconds: measured, + declared_sim_seconds_per_tick: dt, + target_sim_seconds: target, + kept_up: measured <= target + }; + if (!pace.kept_up) reasons.push("wall pace exceeded declared simulation time"); + const evidence: WorldEvidence = { + actions, + perception: { count: perception.length, principals: [...new Set(perception.flatMap((record) => typeof record.principal === "string" ? [record.principal] : []))].sort() }, + refusals: { count: refusals.length, reasons: refusals.flatMap((record) => typeof record.reason === "string" ? [record.reason] : []) }, + possession, + pace, + verdict: { + passed: reasons.length === 0 && incompleteReasons.length === 0, + status: incompleteReasons.length > 0 ? "incomplete" : reasons.length > 0 ? "failed" : "passed", + reasons: [...reasons, ...incompleteReasons] + } + }; + return { evidence, parseErrors }; +}; diff --git a/src/observe/worldGrants.test.ts b/src/observe/worldGrants.test.ts new file mode 100644 index 0000000..29dda9e --- /dev/null +++ b/src/observe/worldGrants.test.ts @@ -0,0 +1,247 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { cp, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import test from "node:test"; + +import { reconcileEvents } from "@noopolis/stele"; + +import { + createDynamicsTestProject, + removeDynamicsTestProject +} from "../dynamics/testSupport.test-helper.js"; +import { buildObserveReport } from "./compute.js"; +import { + parseRunManifest, + RUN_MANIFEST_VERSION, + type SimfileRunManifest +} from "./manifest.js"; +import { + OBSERVE_REPORT_VERSION, + parseObserveReport, + type SimfileObserveReport +} from "./report.js"; +import type { ObserveWorldGrants } from "./worldGrants.js"; + +const packageRoot = path.resolve(fileURLToPath(new URL("../../", import.meta.url))); +const savedReportPath = path.join( + packageRoot, + "fixtures", + "observe", + "office-pressure-v0-golden", + "observe", + "report.json" +); +const savedRunPath = path.resolve(savedReportPath, "..", ".."); + +const captureCli = async ( + argv: readonly string[] +): Promise<{ code: number; stderr: string; stdout: string }> => + new Promise((resolve, reject) => { + const child = spawn(process.execPath, [ + "--import", + "tsx", + fileURLToPath(new URL("../cli/index.ts", import.meta.url)), + ...argv + ], { stdio: ["ignore", "pipe", "pipe"] }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + child.once("error", reject); + child.once("close", (code) => resolve({ + code: code ?? 1, + stderr: Buffer.concat(stderr).toString(), + stdout: Buffer.concat(stdout).toString() + })); + }); + +const manifestFor = (world?: Record): SimfileRunManifest => + parseRunManifest({ + version: RUN_MANIFEST_VERSION, + run_id: "observe-world-grants", + created_at: "2026-07-30T00:00:00.000Z", + contract_versions: {}, + artifacts: [], + ...(world === undefined ? {} : { world }) + }); + +const reportFor = (manifest: SimfileRunManifest): SimfileObserveReport => + buildObserveReport({ + allEvents: [], + manifest, + memoryBanks: [], + reconciled: reconcileEvents([]) + }); + +const grantStates: readonly ObserveWorldGrants[] = [ + { + status: "none-declared", + participants: [], + resolved: false, + observed: false, + deferred_to: "B158" + }, + { + status: "declared-unresolved", + participants: ["alpha", "beta"], + resolved: false, + observed: false, + deferred_to: "B158" + }, + { + status: "declared-resolved", + participants: ["alpha", "beta"], + resolved: true, + observed: true + } +]; + +test("all three recorded world-grant states round-trip as distinguishable report evidence", () => { + const reports = grantStates.map((marker) => + parseObserveReport(reportFor(manifestFor({ world_grants: marker }))) + ); + + assert.deepEqual( + reports.map((report) => report.world_grants), + grantStates + ); + assert.deepEqual( + reports.map((report) => report.world_grants?.status), + ["none-declared", "declared-unresolved", "declared-resolved"] + ); + assert.equal(new Set(reports.map((report) => report.world_grants?.status)).size, 3); +}); + +test("a missing marker remains absent and distinct from none-declared", () => { + const absent = parseObserveReport(reportFor(manifestFor({ decision_source: "none" }))); + const noneDeclared = parseObserveReport(reportFor(manifestFor({ + world_grants: grantStates[0] + }))); + + assert.equal(Object.hasOwn(absent, "world_grants"), false); + assert.equal(absent.world_grants, undefined); + assert.equal(Object.hasOwn(noneDeclared, "world_grants"), true); + assert.equal(noneDeclared.world_grants?.status, "none-declared"); +}); + +test("a malformed recorded marker fails loudly and names the offending field", () => { + const manifest = manifestFor({ + world_grants: { + status: "declared-unresolved", + participants: ["alpha"], + resolved: "not-a-boolean", + observed: false + } + }); + + assert.throws( + () => reportFor(manifest), + /invalid manifest world\.world_grants marker: world\.world_grants\.resolved:/u + ); +}); + +test("a committed saved v1 report without world_grants remains valid", async () => { + const saved = JSON.parse(await readFile(savedReportPath, "utf8")) as unknown; + assert.equal( + Object.hasOwn(saved as Record, "world_grants"), + false + ); + + const parsed = parseObserveReport(saved); + assert.equal(parsed.version, "simfile.observe.v1"); + assert.equal(parsed.world_grants, undefined); + assert.equal(OBSERVE_REPORT_VERSION, "simfile.observe.v1"); +}); + +test("spawned run and observe commands surface B157 markers in JSON and plain output", async () => { + const declaredProject = await createDynamicsTestProject(); + const undeclaredProject = await createDynamicsTestProject(); + try { + const declaredSource = await readFile(declaredProject.simfilePath, "utf8"); + await writeFile(declaredProject.simfilePath, `${declaredSource} +world: + id: grant-observe + grants: + beta: + entity: entity:beta + alpha: + entity: entity:alpha +`, "utf8"); + + const variants = [ + { + name: "declared", + project: declaredProject, + expected: { + status: "declared-unresolved", + participants: ["alpha", "beta"], + resolved: false, + observed: false + }, + plain: "world grants: declared-unresolved (resolved=false, observed=false, participants: alpha, beta)" + }, + { + name: "undeclared", + project: undeclaredProject, + expected: { + status: "none-declared", + participants: [], + resolved: false, + observed: false + }, + plain: "world grants: none-declared (resolved=false, observed=false, participants: none)" + } + ] as const; + + for (const variant of variants) { + const runDirectory = path.join(variant.project.directory, `${variant.name}-run`); + const run = await captureCli([ + "run", + variant.project.simfilePath, + "--ticks", + "1", + "--out", + runDirectory, + "--run-id", + `${variant.name}-grants` + ]); + assert.equal(run.code, 0, run.stderr); + assert.equal(run.stderr, ""); + + const jsonObserve = await captureCli(["observe", runDirectory, "--json"]); + assert.equal(jsonObserve.code, 0, jsonObserve.stderr); + assert.equal(jsonObserve.stderr, ""); + const payload = JSON.parse(jsonObserve.stdout) as { + report: { world_grants?: ObserveWorldGrants }; + }; + assert.deepEqual(payload.report.world_grants, variant.expected); + + const plainObserve = await captureCli(["observe", runDirectory]); + assert.equal(plainObserve.code, 0, plainObserve.stderr); + assert.equal(plainObserve.stderr, ""); + assert.equal(plainObserve.stdout.split("\n").includes(variant.plain), true); + } + } finally { + await Promise.all([ + removeDynamicsTestProject(declaredProject), + removeDynamicsTestProject(undeclaredProject) + ]); + } +}); + +test("plain output distinguishes a pre-marker run as not recorded", async () => { + const temporaryRoot = await mkdtemp(path.join(tmpdir(), "simfile-observe-pre-marker-")); + const copiedRun = path.join(temporaryRoot, "run"); + try { + await cp(savedRunPath, copiedRun, { recursive: true }); + const observed = await captureCli(["observe", copiedRun]); + assert.equal(observed.code, 0, observed.stderr); + assert.equal(observed.stderr, ""); + assert.equal(observed.stdout.split("\n").includes("world grants: not recorded"), true); + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } +}); diff --git a/src/observe/worldGrants.ts b/src/observe/worldGrants.ts new file mode 100644 index 0000000..5fcd79f --- /dev/null +++ b/src/observe/worldGrants.ts @@ -0,0 +1,52 @@ +/** + * World-grant observation has three recorded states plus absence: an absent + * marker means no marker was recorded; `none-declared` means Simfile declared + * no world grants; `declared-unresolved` means grants were declared but have + * not yet been resolved or observed; and `declared-resolved` means declared + * grants were resolved. + */ +import { z } from "zod"; + +import type { SimfileRunManifest } from "./manifest.js"; + +export const OBSERVE_WORLD_GRANT_STATUSES = [ + "none-declared", + "declared-unresolved", + "declared-resolved" +] as const; + +/** + * Forward compatibility is deliberately fail-loud: a future producer status + * or marker field must not be silently discarded or misclassified by an older + * observer. Extend this schema when the manifest marker contract grows. + */ +export const observeWorldGrantsSchema = z + .object({ + status: z.enum(OBSERVE_WORLD_GRANT_STATUSES), + participants: z.array(z.string().min(1)), + resolved: z.boolean(), + observed: z.boolean(), + deferred_to: z.string().min(1).optional() + }) + .strict(); + +export type ObserveWorldGrants = z.infer; + +export const worldGrantsFromManifest = ( + manifest: SimfileRunManifest +): ObserveWorldGrants | undefined => { + const marker = manifest.world?.world_grants; + if (marker === undefined) return undefined; + + const result = observeWorldGrantsSchema.safeParse(marker); + if (!result.success) { + throw new Error( + `invalid manifest world.world_grants marker: ${result.error.issues + .map((issue) => + `world.world_grants.${issue.path.join(".") || ""}: ${issue.message}` + ) + .join("; ")}` + ); + } + return result.data; +}; diff --git a/src/ownership/AGENTS.md b/src/ownership/AGENTS.md new file mode 100644 index 0000000..fa6798a --- /dev/null +++ b/src/ownership/AGENTS.md @@ -0,0 +1,16 @@ +# Ownership Guards + +This folder holds generic dependency and build-boundary checks. Keep these +checks independent of any individual simulation fixture. + +- `fixtureDependencyScanner.test-helper.ts` provides the AST scanner for + fixture-to-public-package dependencies. Keep it assertion-free, + test-runner-free, and limited to named exports. +- `fixtureDependencyScanner.test.ts` proves the scanner fails closed for + unsupported relative, computed, aliased, and escaped dependencies. +- `publicPackageBuildGate.test.ts` owns interruption, marker-integrity, and + Vite-input invalidation proofs for the shared public-package build gate. + +Do not add a fixture vocabulary, participant name, world identifier, or timing +constant to these generic guards. A simulation-specific ownership census or +neutrality ratchet belongs with that maintained simulation instead. diff --git a/src/ownership/CLAUDE.md b/src/ownership/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/ownership/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/ownership/fixtureDependencyScanner.test-helper.ts b/src/ownership/fixtureDependencyScanner.test-helper.ts new file mode 100644 index 0000000..ecc4838 --- /dev/null +++ b/src/ownership/fixtureDependencyScanner.test-helper.ts @@ -0,0 +1,348 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import path from "node:path"; +import ts from "typescript"; + +export interface ComputedDynamicDependencyException { + readonly path: string; + readonly call: "import" | "require"; + readonly dynamic_call_ordinal: number; + readonly normalized_expression: string; + readonly normalized_expression_sha256: string; + readonly purpose: string; +} +export type PublicImports = Map; types: Set }>; +type DynamicCall = { + call: "import" | "require"; + directRequire: boolean; + argument: ts.Expression | undefined; + argumentCount: number; +}; +type RequireTaint = "require" | "call" | "apply"; +type RequireAnalysis = { + aliases: ReadonlyMap; + dynamicCalls: ReadonlyMap; + allowedReferences: ReadonlySet; +}; +const extensions = [".ts", ".mts", ".cts", ".mjs", ".cjs", ".js"]; + +const normalizeExpression = (expression: ts.Expression, source: ts.SourceFile): string => + ts.createPrinter({ removeComments: true }) + .printNode(ts.EmitHint.Expression, expression, source).trim(); +export const expressionHash = (expression: string): string => + createHash("sha256").update(expression).digest("hex"); +const unparenthesized = (expression: ts.Expression): ts.Expression => { + while (ts.isParenthesizedExpression(expression)) expression = expression.expression; + return expression; +}; +const requireTaint = ( + expression: ts.Expression, + aliases: ReadonlyMap, +): RequireTaint | undefined => { + expression = unparenthesized(expression); + if (ts.isBinaryExpression(expression) + && expression.operatorToken.kind === ts.SyntaxKind.CommaToken) { + return requireTaint(expression.right, aliases); + } + if (ts.isIdentifier(expression)) { + return expression.text === "require" ? "require" : aliases.get(expression.text); + } + if (ts.isPropertyAccessExpression(expression) || ts.isElementAccessExpression(expression)) { + if (requireTaint(expression.expression, aliases) !== "require") return undefined; + const property = ts.isPropertyAccessExpression(expression) + ? expression.name.text + : expression.argumentExpression && ts.isStringLiteral(expression.argumentExpression) + ? expression.argumentExpression.text : undefined; + return property === "call" || property === "apply" ? property : undefined; + } + return undefined; +}; +const aliasBindings = (source: ts.SourceFile): ReadonlyMap => { + const aliases = new Map(); + let changed = true; + while (changed) { + changed = false; + const visit = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) { + const taint = requireTaint(node.initializer, aliases); + if (taint && aliases.get(node.name.text) !== taint) { + aliases.set(node.name.text, taint); + changed = true; + } + } + ts.forEachChild(node, visit); + }; + visit(source); + } + return aliases; +}; +const dynamicCall = ( + call: ts.CallExpression, + aliases: ReadonlyMap, +): DynamicCall | undefined => { + if (call.expression.kind === ts.SyntaxKind.ImportKeyword) { + return { call: "import", directRequire: true, + argument: call.arguments[0], argumentCount: call.arguments.length }; + } + const taint = requireTaint(unparenthesized(call.expression), aliases); + if (taint === "require") return { call: "require", directRequire: true, + argument: call.arguments[0], argumentCount: call.arguments.length }; + if (taint === "call") return { call: "require", directRequire: false, + argument: call.arguments[1], argumentCount: Math.max(0, call.arguments.length - 1) }; + if (taint !== "apply") return undefined; + const applied = call.arguments.length === 2 && ts.isArrayLiteralExpression(call.arguments[1]) + && call.arguments[1].elements.length === 1 + && ts.isExpression(call.arguments[1].elements[0]) + ? call.arguments[1].elements[0] : undefined; + return { call: "require", directRequire: false, argument: applied, + argumentCount: applied ? 1 : 0 }; +}; +const isBindingIdentifier = (identifier: ts.Identifier): boolean => { + const { parent } = identifier; + return ts.isVariableDeclaration(parent) && parent.name === identifier + || ts.isBindingElement(parent) && parent.name === identifier + || ts.isParameter(parent) && parent.name === identifier + || ts.isFunctionDeclaration(parent) && parent.name === identifier + || ts.isFunctionExpression(parent) && parent.name === identifier + || ts.isClassDeclaration(parent) && parent.name === identifier + || ts.isClassExpression(parent) && parent.name === identifier + || ts.isImportClause(parent) && parent.name === identifier + || ts.isImportSpecifier(parent) && parent.name === identifier + || ts.isNamespaceImport(parent) && parent.name === identifier + || ts.isImportEqualsDeclaration(parent) && parent.name === identifier + || ts.isCatchClause(parent) && parent.variableDeclaration?.name === identifier; +}; +const markTaintedReferences = ( + expression: ts.Expression, + aliases: ReadonlyMap, + allowed: Set, +): void => { + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node) && !isBindingIdentifier(node) + && (node.text === "require" || aliases.has(node.text))) allowed.add(node); + ts.forEachChild(node, visit); + }; + visit(expression); +}; +const requireAnalysis = (source: ts.SourceFile): RequireAnalysis => { + const aliases = aliasBindings(source); + const dynamicCalls = new Map(); + const allowedReferences = new Set(); + const visit = (node: ts.Node): void => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer + && requireTaint(node.initializer, aliases)) { + markTaintedReferences(node.initializer, aliases, allowedReferences); + } + if (ts.isCallExpression(node)) { + const dynamic = dynamicCall(node, aliases); + if (dynamic) { + dynamicCalls.set(node, dynamic); + markTaintedReferences(node.expression, aliases, allowedReferences); + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return { aliases, dynamicCalls, allowedReferences }; +}; +const assertAllRequireReferencesAccounted = ( + source: ts.SourceFile, + consumer: string, + analysis: RequireAnalysis, +): void => { + const visit = (node: ts.Node): void => { + if (ts.isIdentifier(node) && !isBindingIdentifier(node) + && (node.text === "require" || analysis.aliases.has(node.text))) { + assert.ok(analysis.allowedReferences.has(node), + `${consumer} uses an unproved require reference`); + } + ts.forEachChild(node, visit); + }; + visit(source); +}; +const resolveImport = ( + from: string, + specifier: string, + candidates: ReadonlySet, +): string | undefined => { + if (!specifier.startsWith(".")) return undefined; + const base = path.posix.normalize(path.posix.join(path.posix.dirname(from), specifier)); + return [ + base, + ...extensions.map((extension) => base.replace(/\.[^/.]+$/u, extension)), + ...extensions.map((extension) => `${base}${extension}`), + ...extensions.map((extension) => `${base}/index${extension}`), + ].find((candidate) => candidates.has(candidate)); +}; + +export const discoverRelativeDependencies = ( + sourceText: string, + consumer: string, + candidates: ReadonlySet, +): string[] => { + const source = ts.createSourceFile(consumer, sourceText, ts.ScriptTarget.Latest, true); + const analysis = requireAnalysis(source); + const dependencies = new Set(); + const add = (literal: ts.Expression | undefined) => { + if (literal && ts.isStringLiteral(literal)) { + const resolved = resolveImport(consumer, literal.text, candidates); + if (resolved) dependencies.add(resolved); + } + }; + const visit = (node: ts.Node): void => { + if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) add(node.moduleSpecifier); + if (ts.isImportEqualsDeclaration(node) && ts.isExternalModuleReference(node.moduleReference)) { + add(node.moduleReference.expression); + } + if (ts.isCallExpression(node)) add(analysis.dynamicCalls.get(node)?.argument); + ts.forEachChild(node, visit); + }; + visit(source); + return [...dependencies].sort(); +}; + +export const collectPublicImportSymbols = ( + sourceText: string, + consumer: string, + candidates: ReadonlySet, + providers: ReadonlyMap, + imports: PublicImports, + computedExceptions: readonly T[] = [], + usedComputedExceptions = new Map(), +): void => { + const source = ts.createSourceFile(consumer, sourceText, ts.ScriptTarget.Latest, true); + const analysis = requireAnalysis(source); + assertAllRequireReferencesAccounted(source, consumer, analysis); + let dynamicCallOrdinal = 0; + const mappedProvider = (specifier: string) => { + if (!specifier.startsWith(".")) return undefined; + const provider = resolveImport(consumer, specifier, candidates); + return provider && providers.has(provider) + ? { provider, publicSpecifier: providers.get(provider)! } : undefined; + }; + for (const statement of source.statements) { + if (ts.isImportDeclaration(statement) && ts.isStringLiteral(statement.moduleSpecifier)) { + const mapped = mappedProvider(statement.moduleSpecifier.text); + if (!mapped) continue; + assert.ok(statement.importClause && !statement.importClause.name + && statement.importClause.namedBindings + && ts.isNamedImports(statement.importClause.namedBindings) + && statement.importClause.namedBindings.elements.length > 0, + `${consumer} uses an unproved default, namespace, or side-effect import from ${mapped.provider}`); + const group = imports.get(mapped.publicSpecifier) + ?? { values: new Set(), types: new Set() }; + for (const element of statement.importClause.namedBindings.elements) { + const exported = (element.propertyName ?? element.name).text; + assert.notEqual(exported, "default", + `${consumer} uses an unproved default import specifier from ${mapped.provider}`); + (statement.importClause.isTypeOnly || element.isTypeOnly + ? group.types : group.values).add(exported); + } + imports.set(mapped.publicSpecifier, group); + continue; + } + if (ts.isExportDeclaration(statement) && statement.moduleSpecifier + && ts.isStringLiteral(statement.moduleSpecifier)) { + const mapped = mappedProvider(statement.moduleSpecifier.text); + assert.ok(!mapped, `${consumer} uses an unproved export-from dependency on ${mapped?.provider}`); + continue; + } + if (ts.isImportEqualsDeclaration(statement) + && ts.isExternalModuleReference(statement.moduleReference) + && statement.moduleReference.expression + && ts.isStringLiteral(statement.moduleReference.expression)) { + const mapped = mappedProvider(statement.moduleReference.expression.text); + assert.ok(!mapped, `${consumer} uses an unproved import-equals dependency on ${mapped?.provider}`); + } + } + const visit = (node: ts.Node): void => { + if (ts.isCallExpression(node)) { + const dynamic = analysis.dynamicCalls.get(node); + if (!dynamic) { + ts.forEachChild(node, visit); + return; + } + dynamicCallOrdinal += 1; + if (dynamic.call === "import") assert.equal(dynamic.argumentCount, 1, + `${consumer} uses an unproved multi-argument dynamic dependency`); + if (!dynamic.argument || !ts.isStringLiteral(dynamic.argument)) { + const normalized = dynamic.argument && normalizeExpression(dynamic.argument, source); + const exception = computedExceptions.find((candidate) => candidate.path === consumer + && candidate.call === dynamic.call + && candidate.dynamic_call_ordinal === dynamicCallOrdinal + && candidate.normalized_expression === normalized + && candidate.normalized_expression_sha256 === expressionHash(normalized)); + assert.ok(exception && (dynamic.call === "import" || dynamic.directRequire), + `${consumer} uses an unproved nonliteral dynamic dependency`); + usedComputedExceptions.set(exception, (usedComputedExceptions.get(exception) ?? 0) + 1); + ts.forEachChild(node, visit); + return; + } + const mapped = mappedProvider(dynamic.argument.text); + if (mapped) assert.equal(dynamic.argumentCount, 1, + `${consumer} uses an unproved multi-argument dynamic dependency on ${mapped.provider}`); + assert.ok(!mapped, `${consumer} uses an unproved dynamic dependency on ${mapped?.provider}`); + } + ts.forEachChild(node, visit); + }; + visit(source); +}; + +export const assertFixturePlatformImports = ( + sourceText: string, + consumer: string, + candidates: ReadonlySet, + computedExceptions: readonly T[], + usedComputedExceptions = new Map(), +): string[] => { + const source = ts.createSourceFile(consumer, sourceText, ts.ScriptTarget.Latest, true); + const analysis = requireAnalysis(source); + assertAllRequireReferencesAccounted(source, consumer, analysis); + const publicSpecifiers = new Set([ + "simfile", "simfile/dynamics", "simfile/moltnet", "simfile/observe", + "simfile/runtime", "simfile/schema", "simfile/spawnfile", + ]); + const allowedExternal = new Set(["@noopolis/stele", "yaml", "zod"]); + const usedPublic = new Set(); + let dynamicCallOrdinal = 0; + const check = (specifier: string) => { + if (specifier.startsWith(".")) { + assert.ok(resolveImport(consumer, specifier, candidates), + `${consumer} reaches outside the fixture platform through ${specifier}`); + } else if (publicSpecifiers.has(specifier)) usedPublic.add(specifier); + else if (specifier.startsWith("node:") || allowedExternal.has(specifier)) return; + else assert.fail(`${consumer} imports non-public package ${specifier}`); + }; + const visit = (node: ts.Node): void => { + if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) + && node.moduleSpecifier && ts.isStringLiteral(node.moduleSpecifier)) check(node.moduleSpecifier.text); + else if (ts.isImportEqualsDeclaration(node) + && ts.isExternalModuleReference(node.moduleReference) + && node.moduleReference.expression && ts.isStringLiteral(node.moduleReference.expression)) { + check(node.moduleReference.expression.text); + } else if (ts.isCallExpression(node)) { + const dynamic = analysis.dynamicCalls.get(node); + if (dynamic) { + dynamicCallOrdinal += 1; + if (dynamic.call === "import") assert.equal(dynamic.argumentCount, 1, + `${consumer} uses an unproved multi-argument dynamic dependency`); + if (dynamic.argument && ts.isStringLiteral(dynamic.argument)) { + check(dynamic.argument.text); + } else { + const normalized = dynamic.argument && normalizeExpression(dynamic.argument, source); + const exception = computedExceptions.find((candidate) => candidate.path === consumer + && candidate.call === dynamic.call + && candidate.dynamic_call_ordinal === dynamicCallOrdinal + && candidate.normalized_expression === normalized + && candidate.normalized_expression_sha256 === expressionHash(normalized)); + assert.ok(exception && (dynamic.call === "import" || dynamic.directRequire), + `${consumer} uses an unproved nonliteral dynamic dependency`); + usedComputedExceptions.set(exception, (usedComputedExceptions.get(exception) ?? 0) + 1); + } + } + } + ts.forEachChild(node, visit); + }; + visit(source); + return [...usedPublic] .sort(); +}; diff --git a/src/ownership/fixtureDependencyScanner.test.ts b/src/ownership/fixtureDependencyScanner.test.ts new file mode 100644 index 0000000..0772523 --- /dev/null +++ b/src/ownership/fixtureDependencyScanner.test.ts @@ -0,0 +1,83 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + collectPublicImportSymbols, + discoverRelativeDependencies, + expressionHash, + type ComputedDynamicDependencyException, + type PublicImports, +} from "./fixtureDependencyScanner.test-helper.js"; + +test("public dependency scanner rejects every unsupported relative dependency form", () => { + const consumer = "src/sims/scenarioProbe.ts"; + const candidates = new Set([consumer, "src/dynamics/types.ts"]); + const providers = new Map([["src/dynamics/types.ts", "simfile/dynamics"]]); + const reject = (source: string, pattern: RegExp) => { + assert.deepEqual(discoverRelativeDependencies(source, consumer, candidates), ["src/dynamics/types.ts"]); + assert.throws(() => collectPublicImportSymbols(source, consumer, candidates, providers, new Map()), pattern); + }; + const accepted: PublicImports = new Map(); + collectPublicImportSymbols('import type { DynamicsSession as Session } from "../dynamics/types.js";', consumer, candidates, providers, accepted); + assert.deepEqual([...accepted.get("simfile/dynamics")!.types], ["DynamicsSession"]); + assert.deepEqual(discoverRelativeDependencies('import "../dynamics/types";', consumer, candidates), ["src/dynamics/types.ts"]); + assert.deepEqual(discoverRelativeDependencies('import "../dynamics/types.mjs";', consumer, candidates), ["src/dynamics/types.ts"]); + reject('import {} from "../dynamics/types.js";', /default, namespace, or side-effect/u); + reject('import { default as Session } from "../dynamics/types.js";', /unproved default import specifier/u); + reject('export { DynamicsSession } from "../dynamics/types.js";', /unproved export-from/u); + reject('export * from "../dynamics/types.js";', /unproved export-from/u); + reject('import type Session = require("../dynamics/types.js");', /unproved import-equals/u); + reject('import Session from "../dynamics/types.js";', /default, namespace, or side-effect/u); + reject('import * as dynamics from "../dynamics/types.js";', /default, namespace, or side-effect/u); + reject('void import("../dynamics/types.js");', /unproved dynamic dependency/u); + reject('require("../dynamics/types.js");', /unproved dynamic dependency/u); + reject('const direct = require; const transitive = (0, direct); transitive("../dynamics/types.js");', /unproved dynamic dependency/u); + reject('require("../dynamics/types.js", options);', /multi-argument dynamic dependency/u); + const rejectComputed = (source: string) => { + assert.deepEqual(discoverRelativeDependencies(source, consumer, candidates), []); + assert.throws(() => collectPublicImportSymbols(source, consumer, candidates, providers, new Map()), /unproved nonliteral dynamic dependency/u); + }; + rejectComputed('void import("../dynamics/" + "types.js");'); + rejectComputed('require("../dynamics/" + "types.js");'); + rejectComputed('(0, require)("../dynamics/" + "types.js");'); + rejectComputed('const direct = require; const transitive = (0, direct); (transitive)("../dynamics/" + "types.js");'); + rejectComputed('(require.call)(null, "../dynamics/" + "types.js");'); + rejectComputed('require["apply"](null, ["../dynamics/" + "types.js"]);'); + rejectComputed('const call = require.call; call(null, "../dynamics/" + "types.js");'); + rejectComputed('const apply = require.apply; const transitive = apply; transitive(null, ["../dynamics/" + "types.js"]);'); + for (const escapedRequire of [ + 'const pass = (value: unknown) => value; const escaped = pass(require); escaped("../dynamics/types.js");', + 'const escaped = { require }; escaped.require("../dynamics/types.js");', + 'const direct = require; const escaped = [direct]; escaped[0]("../dynamics/types.js");', + 'const call = require.call; const escaped = { call }; escaped.call(null, "../dynamics/types.js");' + ]) assert.throws(() => collectPublicImportSymbols(escapedRequire, consumer, candidates, providers, new Map()), /unproved require reference/u); + assert.throws(() => collectPublicImportSymbols('import("../dynamics/" + "types.js", { with: {} });', consumer, candidates, providers, new Map()), /multi-argument dynamic dependency/u); +}); + +test("computed dynamic dependency exceptions are exact AST proofs", () => { + const consumer = "fixtures/sims/reference-game/platform/dynamics/session.test-helper.ts"; + const source = 'void import(`${pathToFileURL(PROVIDER_MODULE).href}?fixed_test=${Date.now()}-${Math.random()}`);'; + const normalized = '`${pathToFileURL(PROVIDER_MODULE).href}?fixed_test=${Date.now()}-${Math.random()}`'; + const exception: ComputedDynamicDependencyException = { + path: consumer, call: "import", dynamic_call_ordinal: 1, + normalized_expression: normalized, + normalized_expression_sha256: expressionHash(normalized), + purpose: "reload fixture-owned provider module", + }; + const used = new Map(); + const collect = (candidate: string, exceptions = [exception], usage = new Map()) => + collectPublicImportSymbols(candidate, consumer, new Set([consumer]), new Map(), new Map(), exceptions, usage); + collect(source, [exception], used); + assert.equal(used.get(exception), 1); + assert.throws(() => collect(source.replace("Math.random()", "Math.random() + 1")), /unproved nonliteral dynamic dependency/u); + assert.throws(() => collect(`${source}\n${source}`), /unproved nonliteral dynamic dependency/u); + assert.throws(() => collect(`${source.slice(0, -2)}, { with: {} });`), /multi-argument dynamic dependency/u); + for (const hostileRequire of [ + 'require(`${pathToFileURL(PROVIDER_MODULE).href}`);', + '(require)(`${pathToFileURL(PROVIDER_MODULE).href}`);', + 'const r = require; r(`${pathToFileURL(PROVIDER_MODULE).href}`);', + 'const call = require.call; call(null, `${pathToFileURL(PROVIDER_MODULE).href}`);', + 'const apply = require.apply; const delegated = apply; delegated(null, [`${pathToFileURL(PROVIDER_MODULE).href}`]);', + ]) assert.throws(() => collect(hostileRequire), /unproved nonliteral dynamic dependency/u); + assert.throws(() => collect('const pass = (value: unknown) => value; const escaped = pass(require); escaped(`${pathToFileURL(PROVIDER_MODULE).href}`);'), /unproved require reference/u); +}); diff --git a/src/ownership/publicPackageBuildGate.test.ts b/src/ownership/publicPackageBuildGate.test.ts new file mode 100644 index 0000000..e3a9e1a --- /dev/null +++ b/src/ownership/publicPackageBuildGate.test.ts @@ -0,0 +1,149 @@ +import assert from "node:assert/strict"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { spawn } from "node:child_process"; +import { once } from "node:events"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { ensurePublicPackageBuild } from "../publicPackageBuild.test-helper.js"; + +const root = path.resolve(fileURLToPath(new URL("../..", import.meta.url))); +const requiredBuildOutputs = [ + "dist/index.js", "dist/index.d.ts", "dist/schema/index.js", "dist/schema/index.d.ts", + "dist/moltnet/index.js", "dist/moltnet/index.d.ts", "dist/dynamics/index.js", "dist/dynamics/index.d.ts", + "dist/observe/index.js", "dist/observe/index.d.ts", "dist/runtime/index.js", "dist/runtime/index.d.ts", + "dist/spawnfile/index.js", "dist/spawnfile/index.d.ts", "dist/cli/index.js", "dist/cli/index.d.ts", + "web/dist/index.html", "web/dist/assets/probe.js", +]; + +async function buildProbePackage(): Promise<{ root: string; events: string }> { + const probeRoot = await mkdtemp(path.join(tmpdir(), "simfile-public-build-gate-")); + const events = path.join(probeRoot, "events.log"); + await Promise.all([ + mkdir(path.join(probeRoot, "src"), { recursive: true }), + mkdir(path.join(probeRoot, "web", "src"), { recursive: true }), + ]); + await Promise.all([ + writeFile(path.join(probeRoot, "tsconfig.json"), "{}\n"), + writeFile(path.join(probeRoot, "tsconfig.build.json"), "{}\n"), + writeFile(path.join(probeRoot, "tsconfig.web.json"), "{}\n"), + writeFile(path.join(probeRoot, "src", "probe.ts"), "export {};\n"), + writeFile(path.join(probeRoot, "web", "src", "probe.ts"), "export {};\n"), + writeFile(path.join(probeRoot, "web", "vite.config.ts"), "export default {};\n"), + writeFile(path.join(probeRoot, "package.json"), '{"scripts":{"build":"node build.mjs"}}\n'), + ]); + await writeFile(path.join(probeRoot, "build.mjs"), [ + 'import { appendFile, mkdir, writeFile } from "node:fs/promises";', + 'import path from "node:path";', + 'const root = process.cwd();', + 'const events = process.env.SIMFILE_PUBLIC_BUILD_EVENTS;', + 'if (events) await appendFile(events, `start ${process.pid}\\n`);', + 'await new Promise((resolve) => setTimeout(resolve, 700));', + `for (const output of ${JSON.stringify(requiredBuildOutputs)}) {`, + ' const destination = path.join(root, output);', + ' await mkdir(path.dirname(destination), { recursive: true });', + ' await writeFile(destination, "ok\\n");', + '}', + 'if (events) await appendFile(events, `finish ${process.pid}\\n`);', + ].join("\n")); + return { root: probeRoot, events }; +} + +const buildEvents = async (events: string): Promise => + (await readFile(events, "utf8")).trim().split("\n").map((line) => line.split(" ")[0]!); + +async function startInterruptedBuild(probeRoot: string, stage: "before" | "after") { + const runner = path.join(probeRoot, "interrupted-build.mts"); + const helper = new URL("../publicPackageBuild.test-helper.ts", import.meta.url).href; + await writeFile(runner, [ + `import { ensurePublicPackageBuild } from ${JSON.stringify(helper)};`, + 'const [stage, packageRoot] = process.argv.slice(2);', + 'const pause = async () => { process.send?.(stage); await new Promise(() => {}); };', + 'await ensurePublicPackageBuild(packageRoot, stage === "before"', + ' ? { afterGatedChildSpawned: pause }', + ' : { afterGateOpened: pause });', + ].join("\n")); + const child = spawn(process.execPath, ["--import", "tsx", runner, stage, probeRoot], { + cwd: root, + env: { ...process.env, SIMFILE_PUBLIC_BUILD_EVENTS: path.join(probeRoot, "events.log") }, + stdio: ["ignore", "pipe", "pipe", "ipc"], + }); + const [message] = await once(child, "message"); + assert.equal(message, stage); + return child; +} + +test("public package build gate contains a killed parent before and after its child starts", { skip: process.platform === "win32" }, async () => { + const previousEvents = process.env.SIMFILE_PUBLIC_BUILD_EVENTS; + const before = await buildProbePackage(); + const after = await buildProbePackage(); + try { + process.env.SIMFILE_PUBLIC_BUILD_EVENTS = before.events; + const beforeRunner = await startInterruptedBuild(before.root, "before"); + process.kill(beforeRunner.pid!, "SIGKILL"); + await once(beforeRunner, "close"); + await ensurePublicPackageBuild(before.root); + assert.deepEqual(await buildEvents(before.events), ["start", "finish"], + "a parent killed before opening the gate must not launch its build"); + + process.env.SIMFILE_PUBLIC_BUILD_EVENTS = after.events; + const afterRunner = await startInterruptedBuild(after.root, "after"); + process.kill(afterRunner.pid!, "SIGKILL"); + await once(afterRunner, "close"); + await ensurePublicPackageBuild(after.root); + assert.deepEqual(await buildEvents(after.events), ["start", "finish", "start", "finish"], + "a parent killed after opening the gate must retain the child lock until that build exits"); + } finally { + if (previousEvents === undefined) delete process.env.SIMFILE_PUBLIC_BUILD_EVENTS; + else process.env.SIMFILE_PUBLIC_BUILD_EVENTS = previousEvents; + await Promise.all([rm(before.root, { force: true, recursive: true }), rm(after.root, { force: true, recursive: true })]); + } +}); + +test("public package build marker authenticates the complete output tree and root compiler config", async () => { + const previousEvents = process.env.SIMFILE_PUBLIC_BUILD_EVENTS; + const probe = await buildProbePackage(); + try { + process.env.SIMFILE_PUBLIC_BUILD_EVENTS = probe.events; + await ensurePublicPackageBuild(probe.root); + await ensurePublicPackageBuild(probe.root); + assert.deepEqual(await buildEvents(probe.events), ["start", "finish"], "an intact complete marker must reuse its build"); + await rm(path.join(probe.root, "web", "dist", "assets", "probe.js")); + await ensurePublicPackageBuild(probe.root); + assert.deepEqual(await buildEvents(probe.events), ["start", "finish", "start", "finish"], "a missing emitted web asset must force a rebuild"); + await writeFile(path.join(probe.root, "tsconfig.json"), '{"compilerOptions":{"target":"ES2023"}}\n'); + await ensurePublicPackageBuild(probe.root); + assert.deepEqual(await buildEvents(probe.events), ["start", "finish", "start", "finish", "start", "finish"], "a root compiler-config change must force a rebuild"); + await writeFile(path.join(probe.root, "dist", "index.js"), "corrupt\n"); + await ensurePublicPackageBuild(probe.root); + assert.deepEqual(await buildEvents(probe.events), ["start", "finish", "start", "finish", "start", "finish", "start", "finish"], "a changed emitted output byte must force a rebuild"); + } finally { + if (previousEvents === undefined) delete process.env.SIMFILE_PUBLIC_BUILD_EVENTS; + else process.env.SIMFILE_PUBLIC_BUILD_EVENTS = previousEvents; + await rm(probe.root, { force: true, recursive: true }); + } +}); + +test("public package build marker hashes every Vite input outside generated web output", async () => { + const previousEvents = process.env.SIMFILE_PUBLIC_BUILD_EVENTS; + const probe = await buildProbePackage(); + try { + process.env.SIMFILE_PUBLIC_BUILD_EVENTS = probe.events; + await ensurePublicPackageBuild(probe.root); + await writeFile(path.join(probe.root, "web", "index.html"), "
changed index
\n"); + await ensurePublicPackageBuild(probe.root); + await mkdir(path.join(probe.root, "web", "public"), { recursive: true }); + await writeFile(path.join(probe.root, "web", "public", "changed-asset.txt"), "changed public asset\n"); + await ensurePublicPackageBuild(probe.root); + await writeFile(path.join(probe.root, "web", "new-web-input.ts"), "export const changed = true;\n"); + await ensurePublicPackageBuild(probe.root); + assert.deepEqual(await buildEvents(probe.events), ["start", "finish", "start", "finish", "start", "finish", "start", "finish"], + "index.html, public assets, and newly added web inputs must each invalidate the successful-build marker"); + } finally { + if (previousEvents === undefined) delete process.env.SIMFILE_PUBLIC_BUILD_EVENTS; + else process.env.SIMFILE_PUBLIC_BUILD_EVENTS = previousEvents; + await rm(probe.root, { force: true, recursive: true }); + } +}); diff --git a/src/publicPackageBuild.test-helper.ts b/src/publicPackageBuild.test-helper.ts new file mode 100644 index 0000000..6bff88a --- /dev/null +++ b/src/publicPackageBuild.test-helper.ts @@ -0,0 +1,278 @@ +import { spawn } from "node:child_process"; +import { createHash, randomUUID } from "node:crypto"; +import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; + +const LOCK_WAIT_MS = 120_000; +const LOCK_RETRY_MS = 50; +const EMPTY_LOCK_RECOVERY_MS = 5_000; +const BUILD_MARKER = ".simfile-public-package-build-success.json"; +const BUILD_MARKER_VERSION = "simfile.public-package-build.v2"; +const BUILD_INPUTS = ["package.json", "tsconfig.json", "tsconfig.build.json", "tsconfig.web.json", "src", "web"]; +const BUILD_OUTPUT_DIRECTORIES = ["dist", "web/dist"] as const; + +type LockOwner = { token: string; pid: number; childPid?: number; processGroupId?: number }; +type PublicPackageBuildLock = { + release: () => Promise; + trackBuild: (childPid: number) => Promise; +}; +export type PublicPackageBuildTestHooks = { + /** Test-only interruption point: the gated POSIX child exists but cannot build yet. */ + afterGatedChildSpawned?: () => Promise | void; + /** Test-only interruption point: the build PID/group is durable, but its gate is still closed. */ + afterOwnerRecordedBeforeGate?: () => Promise | void; + /** Test-only interruption point: the gate is open and the durable child can exec npm. */ + afterGateOpened?: () => Promise | void; +}; +type BuildMarker = { + version: typeof BUILD_MARKER_VERSION; + inputDigest: string; + outputs: readonly BuildOutput[]; +}; +type BuildOutput = { path: string; sha256: string }; + +const pause = (milliseconds: number): Promise => new Promise((resolve) => setTimeout(resolve, milliseconds)); + +const digestPath = async (root: string, relative: string, hash: ReturnType): Promise => { + // Vite emits to web/dist. Hash the full source tree so index.html, public + // assets, and newly introduced inputs invalidate the marker, but never hash + // the output tree the marker is meant to validate. + if (relative === "web/dist" || relative.startsWith("web/dist/")) return; + const candidate = path.join(root, relative); + const details = await stat(candidate); + if (details.isDirectory()) { + hash.update(`directory:${relative}\0`); + const entries = await readdir(candidate, { withFileTypes: true }); + for (const { name } of entries.sort((left, right) => left.name.localeCompare(right.name))) { + await digestPath(root, path.join(relative, name), hash); + } + return; + } + hash.update(`file:${relative}\0`); + hash.update(await readFile(candidate)); +}; + +const buildInputDigest = async (packageRoot: string): Promise => { + const hash = createHash("sha256"); + for (const input of BUILD_INPUTS) await digestPath(packageRoot, input, hash); + return hash.digest("hex"); +}; + +const normalized = (relative: string): string => relative.split(path.sep).join("/"); + +const buildOutputManifest = async (packageRoot: string): Promise => { + const outputs: BuildOutput[] = []; + const collect = async (relative: string): Promise => { + const entries = await readdir(path.join(packageRoot, relative), { withFileTypes: true }); + for (const entry of entries.sort((left, right) => left.name.localeCompare(right.name))) { + const child = path.join(relative, entry.name); + if (entry.isDirectory()) { + await collect(child); + continue; + } + if (!entry.isFile()) throw new Error(`public package build output contains unsupported entry ${normalized(child)}`); + if (normalized(child) === `dist/${BUILD_MARKER}`) continue; + outputs.push({ + path: normalized(child), + sha256: createHash("sha256").update(await readFile(path.join(packageRoot, child))).digest("hex") + }); + } + }; + for (const directory of BUILD_OUTPUT_DIRECTORIES) await collect(directory); + if (outputs.length === 0) throw new Error("public package build produced no output files"); + return outputs; +}; + +const hasSuccessfulBuild = async (packageRoot: string): Promise => { + let marker: BuildMarker; + try { + marker = JSON.parse(await readFile(path.join(packageRoot, "dist", BUILD_MARKER), "utf8")) as BuildMarker; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT" || error instanceof SyntaxError) return false; + throw error; + } + if (marker.version !== BUILD_MARKER_VERSION + || marker.inputDigest !== await buildInputDigest(packageRoot)) return false; + try { + return JSON.stringify(marker.outputs) === JSON.stringify(await buildOutputManifest(packageRoot)); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +}; + +const writeBuildMarker = async (packageRoot: string, inputDigest: string): Promise => { + const outputs = await buildOutputManifest(packageRoot); + const markerPath = path.join(packageRoot, "dist", BUILD_MARKER); + const temporaryPath = `${markerPath}.${process.pid}.${randomUUID()}.tmp`; + await writeFile(temporaryPath, `${JSON.stringify({ version: BUILD_MARKER_VERSION, inputDigest, outputs })}\n`); + await rename(temporaryPath, markerPath); +}; + +const writeLockOwner = async (lockPath: string, owner: LockOwner): Promise => { + const destination = path.join(lockPath, "owner.json"); + const temporary = `${destination}.${owner.token}.tmp`; + await writeFile(temporary, `${JSON.stringify(owner)}\n`); + await rename(temporary, destination); +}; + +const processIsAlive = (pid: number): boolean => { + try { + process.kill(pid, 0); + return true; + } catch (error) { + return (error as NodeJS.ErrnoException).code !== "ESRCH"; + } +}; + +const emptyLockIsRecoverable = async (lockPath: string): Promise => { + try { + return Date.now() - (await stat(lockPath)).mtimeMs >= EMPTY_LOCK_RECOVERY_MS; + } catch (error) { + // The owner released between mkdir's EEXIST result and our inspection. + // Let the acquisition loop retry mkdir instead of turning that normal race + // into a test/build failure. + if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; + throw error; + } +}; + +const lockOwnerIsGone = async (lockPath: string): Promise => { + // Windows has no equivalent to the POSIX detached process-group proof below. + // A failed owner can leave npm descendants that are not safely attributable, so + // never reclaim a Windows lock automatically: waiters fail closed instead. + if (process.platform === "win32") return false; + let owner: Partial; + try { + owner = JSON.parse(await readFile(path.join(lockPath, "owner.json"), "utf8")) as Partial; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; + return emptyLockIsRecoverable(lockPath); + } + if (typeof owner.token !== "string" || typeof owner.pid !== "number" || !Number.isInteger(owner.pid) || owner.pid <= 0) { + return emptyLockIsRecoverable(lockPath); + } + if (processIsAlive(owner.pid)) return false; + if (typeof owner.childPid === "number" && Number.isInteger(owner.childPid) && owner.childPid > 0 && processIsAlive(owner.childPid)) return false; + if (typeof owner.processGroupId === "number" && Number.isInteger(owner.processGroupId) && owner.processGroupId > 0 && processIsAlive(-owner.processGroupId)) return false; + return true; +}; + +const reclaimStaleLock = async (lockPath: string): Promise => { + // Never recursively remove the shared pathname after observing stale state: + // another waiter could have already replaced it with a live lock. Rename is + // atomic, so a loser only sees ENOENT and cannot delete that new owner. + const reclaimedPath = `${lockPath}.reclaim-${process.pid}-${randomUUID()}`; + try { + await rename(lockPath, reclaimedPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return; + throw error; + } + await rm(reclaimedPath, { force: true, recursive: true }); +}; + +const acquireBuildLock = async (packageRoot: string): Promise => { + const key = createHash("sha256").update(path.resolve(packageRoot)).digest("hex"); + const lockPath = path.join(tmpdir(), `simfile-public-package-build-${key}.lock`); + const deadline = Date.now() + LOCK_WAIT_MS; + while (true) { + try { + await mkdir(lockPath); + const owner: LockOwner = { token: randomUUID(), pid: process.pid }; + try { + await writeLockOwner(lockPath, owner); + } catch (error) { + await rm(lockPath, { force: true, recursive: true }); + throw error; + } + return { + trackBuild: async (childPid) => { + owner.childPid = childPid; + if (process.platform !== "win32") owner.processGroupId = childPid; + await writeLockOwner(lockPath, owner); + }, + release: async () => { + try { + const current = JSON.parse(await readFile(path.join(lockPath, "owner.json"), "utf8")) as Partial; + if (current.token === owner.token) await rm(lockPath, { force: true, recursive: true }); + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "ENOENT" && !(error instanceof SyntaxError)) throw error; + } + } + }; + } catch (error) { + if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; + if (await lockOwnerIsGone(lockPath)) { + await reclaimStaleLock(lockPath); + continue; + } + if (Date.now() >= deadline) throw new Error(`timed out waiting ${LOCK_WAIT_MS}ms for public package build lock at ${lockPath}`); + await pause(LOCK_RETRY_MS); + } + } +}; + +const endGate = async (gate: NodeJS.WritableStream): Promise => new Promise((resolve, reject) => { + gate.once("error", reject); + gate.end("build\n", resolve); +}); + +const isWritableGate = (stream: NodeJS.ReadableStream | NodeJS.WritableStream | null | undefined): stream is NodeJS.WritableStream => + stream !== null && stream !== undefined && "end" in stream && typeof stream.end === "function"; + +const terminateBuild = (child: ReturnType): void => { + if (child.pid === undefined) return; + if (process.platform !== "win32") process.kill(-child.pid, "SIGTERM"); + else child.kill("SIGTERM"); +}; + +const runBuild = async (packageRoot: string, lock: PublicPackageBuildLock, testHooks?: PublicPackageBuildTestHooks): Promise => { + // The shell is the detached group leader and execs npm after the parent has + // atomically recorded that PID/group in owner.json. If the parent dies while + // the gate is closed, EOF makes the shell exit without starting a build. + const posixGate = process.platform !== "win32"; + const child = posixGate + ? spawn("/bin/sh", ["-c", "IFS= read -r _ <&3 || exit 97; exec npm run build"], { cwd: packageRoot, detached: true, stdio: ["ignore", "inherit", "inherit", "pipe"] }) + : spawn("npm", ["run", "build"], { cwd: packageRoot, detached: false, stdio: "inherit" }); + const completed = new Promise((resolve, reject) => { + child.once("error", reject); + child.once("close", (code, signal) => { + if (code === 0) resolve(); + else reject(new Error(`npm run build failed (${signal ? `signal ${signal}` : `exit ${code}`})`)); + }); + }); + try { + if (posixGate) await testHooks?.afterGatedChildSpawned?.(); + await lock.trackBuild(child.pid!); + if (posixGate) await testHooks?.afterOwnerRecordedBeforeGate?.(); + if (posixGate) { + const gate = child.stdio[3]; + if (!isWritableGate(gate)) throw new Error("public package build gate was not created"); + await endGate(gate); + await testHooks?.afterGateOpened?.(); + } + } catch (error) { + terminateBuild(child); + await completed.catch(() => undefined); + throw error; + } + await completed; +}; + +/** Serializes and validates physical package builds requested by public-surface tests. */ +export const ensurePublicPackageBuild = async (packageRoot: string, testHooks?: PublicPackageBuildTestHooks): Promise => { + const resolvedRoot = path.resolve(packageRoot); + const lock = await acquireBuildLock(resolvedRoot); + try { + if (await hasSuccessfulBuild(resolvedRoot)) return; + const beforeBuild = await buildInputDigest(resolvedRoot); + await runBuild(resolvedRoot, lock, testHooks); + const afterBuild = await buildInputDigest(resolvedRoot); + if (beforeBuild !== afterBuild) throw new Error("public package build inputs changed while npm run build was running"); + await writeBuildMarker(resolvedRoot, afterBuild); + } finally { + await lock.release(); + } +}; diff --git a/src/report/probes.test.ts b/src/report/probes.test.ts index 0c9a7b4..283f266 100644 --- a/src/report/probes.test.ts +++ b/src/report/probes.test.ts @@ -42,7 +42,7 @@ const traceArtifact: ProbeArtifact = { }, { event_id: "run:11", - kind: "wake.recommended", + kind: "world.dm", sim_time: 60, scope: "room:a", actor: "deadline_bites", @@ -99,7 +99,7 @@ describe("evaluateProbe", () => { it("supports any composition", () => { const probe: ProbeDefinition = { - when: { any: [{ event: "world.message" }, { event: "wake.recommended" }] }, + when: { any: [{ event: "world.message" }, { event: "clock.sync" }] }, expect: { at_least: 1 } }; const result = evaluateProbe("event_any", events, probe); @@ -169,7 +169,7 @@ describe("evaluateProbe", () => { it("supports bounded after windows between events and state samples", () => { const probe: ProbeDefinition = { when: { variable: "pressure", above: 0.9, for: "60s" }, - after: { event: "wake.recommended", target: "room:a" }, + after: { event: "world.dm", target: "room:a" }, within: "2m", expect: { at_least: 1 } }; diff --git a/src/run/AGENTS.md b/src/run/AGENTS.md new file mode 100644 index 0000000..5203184 --- /dev/null +++ b/src/run/AGENTS.md @@ -0,0 +1,66 @@ +# Local Run Driver + +This folder owns the local deterministic dispatch seam used by explicit local +diagnostics and backward-compatible unlinked `simfile run` execution. It is not +the linked-project lifecycle composer. + +## Boundaries + +- Follow `docs/DESIGN.md` and, when working in the parent Spawnfile repository, + its `specs/ECOSYSTEM_RUNTIME_BOUNDARIES.md`; that upstream specification is + not included in a standalone Simfile checkout. +- Keep the trace path byte-compatible. Dispatch above `writeRunRecord`; do not + change `runSimfileTrace` or the trace record writer. +- A sealed run action source is one scripted/non-live tick notification, not + agent cognition or participant scheduling. Never await or retry it, iterate + its participant declaration as a roster, or fabricate an action from silence. +- Never expose the raw dynamics session to an authored source or import a + fixture controller. Scripted actions use the generic controller authority; + independently submitted agent actions use `WorldRuntime.act()`. Both routes + converge on the existing dynamics ingress queue. +- The dynamics path has no agent cognition, provider transport, action + synthesis, polling, pacing, or retry. +- Do not add world-service or Spawnfile lifecycle operations here. A linked + project is routed by the CLI to the separate generic lifecycle-composition + layer, and any future `simfile dev` wrapper must reuse that layer. +- Do not compose trace mechanics with dynamics here. Mixed declarations fail + closed until B158 defines whole-world checkpoints and interleaving. +- Derive all recorded timestamps from simulated time. Do not record scratch, + staging, or resolved host paths. +- Keep run records honest: empty action inputs and results are present, and + unresolved world grants are labeled explicitly. +- Use named exports, keep files below 400 lines, and keep tests beside the code + they cover. + +## Files added by B192 (motion track) + +- `dynamics-run-artifacts.ts` — exports `dynamicsRunStagingPrefix`, the single + owner of the staging-directory naming convention. + +- `dynamics-run-frames.ts` — `createDynamicsRunFrameRecorder`: writes + `raw/frames.jsonl`, the run's ONLY per-tick spatial artifact, projected from + the provider's optional `spatial()` seam. Header line first (scene bounds + + `sim_seconds_per_tick`), then one line per tick starting at tick 0. Both step + loops call `capture()` after `step()`; miss the action-bearing one + (`dynamics-run-action-ticks.ts`) and exactly the agent-driven matches lose + their motion. Frames go straight to disk and never accumulate in memory. + Action ingress is bounded to one tick and complete evidence is streamed and + acknowledged only after durable append. Append-ordered, fsynced at the + evidence-acknowledgement barrier and at seal, never rewritten, so a partially + written run is a valid prefix that a reader may tail; readers must treat a + torn final line as not-yet-written. +- `dynamics-run-contract-versions.ts` — the manifest's declared contract + version set, extracted from `dynamics-run-record.ts` (behavior-preserving) to + hold that file under 400 lines. New artifacts declare themselves here. +- `raw/world/action-refusals.jsonl` is the always-present, versioned world + ingress-refusal artifact. The action-bearing tick loop drains its optional + host-only refusal port after dynamics ingress evidence and before stepping, + appends the matching causal event, then acknowledges the ordinal. It never + widens the dynamics action-attempt contract or constructs a world runtime. + +## Stage 2 viewer boundary + +The viewer may enumerate staging directories and follow `raw/frames.jsonl`, but +the run production path remains deterministic and unchanged. `dynamicsRunStagingPrefix` +is the sole naming-convention helper; do not duplicate it or add viewer polling, +clocks, timers, or directory enumeration to production files in this folder. diff --git a/src/run/CLAUDE.md b/src/run/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/run/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/run/action-source-packaged.test.ts b/src/run/action-source-packaged.test.ts new file mode 100644 index 0000000..9048709 --- /dev/null +++ b/src/run/action-source-packaged.test.ts @@ -0,0 +1,70 @@ +import assert from "node:assert/strict"; +import path from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { + assertRecordsByteIdentical, + captureCli, + createActionSourceProject, + readJson, + readJsonl +} from "./action-source.test-helper.js"; +import { ensurePublicPackageBuild } from "../publicPackageBuild.test-helper.js"; + +const packageRoot = fileURLToPath(new URL("../../", import.meta.url)); +const packagedCli = path.join(packageRoot, "dist", "cli", "index.js"); + +test("packaged CLI applies participant actions for 40 ticks", async (t) => { + await ensurePublicPackageBuild(packageRoot); + const project = await createActionSourceProject(t, { + actionsEveryTick: ["move:red", "move:blue"], + providerEventCausesAction: true + }); + const left = path.join(project.root, "packaged-left"); + const right = path.join(project.root, "packaged-right"); + for (const out of [left, right]) { + const result = await captureCli( + project.args({ + out, + runId: "packaged-action-source", + ticks: 40 + }), + packagedCli + ); + assert.equal(result.code, 0, result.stderr); + assert.equal(result.stderr, ""); + assert.equal( + result.stdout, + `wrote run packaged-action-source to ${out}\n` + ); + const actions = await readJsonl( + project.file("raw/action-results.jsonl", out), + "nonempty" + ); + assert.deepEqual( + actions.map(({ result }) => [ + result.apply_tick, + result.sequence, + result.origin, + result.accepted + ]), + Array.from({ length: 40 }, (_, tick) => [ + [tick, tick * 2 + 1, "controller", true], + [tick, tick * 2 + 2, "controller", true] + ]).flat() + ); + assert.equal( + (await readJson(project.file("summary.json", out))) + .decision_source.kind, + "controller" + ); + } + await assertRecordsByteIdentical(left, right); + const observed = await captureCli(["observe", left, "--json"], packagedCli); + assert.equal(observed.code, 0, observed.stderr); + const parsed = JSON.parse(observed.stdout) as any; + assert.deepEqual(parsed.causalParseErrors, []); + assert.deepEqual(parsed.report.chains.incomplete, []); + assert.deepEqual(parsed.report.failures, []); +}); diff --git a/src/run/action-source.acceptance.test.ts b/src/run/action-source.acceptance.test.ts new file mode 100644 index 0000000..be575a4 --- /dev/null +++ b/src/run/action-source.acceptance.test.ts @@ -0,0 +1,325 @@ +import assert from "node:assert/strict"; +import { access, readFile } from "node:fs/promises"; +import path from "node:path"; +import test from "node:test"; + +import { + assertRecordsByteIdentical, + captureCli, + createActionSourceProject, + NONE_DECISION_SOURCE, + readJson, + readJsonl, + SCRIPTED_SOURCE +} from "./action-source.test-helper.js"; + +test("A1 no source preserves the B157 dynamics record", async (t) => { + const project = await createActionSourceProject(t, { source: "absent" }); + const result = await captureCli(project.args({ ticks: 4 })); + assert.equal(result.code, 0); + assert.equal(result.stderr, ""); + assert.equal( + result.stdout, + `wrote run action-source-run to ${project.out}\n` + ); + const provenance = await readJson(project.file("provenance.json")); + const summary = await readJson(project.file("summary.json")); + const replay = await readJson( + project.file("replay/action-stream.json") + ); + assert.equal(provenance.action_source, "none"); + assert.deepEqual(provenance.decision_source, NONE_DECISION_SOURCE); + assert.deepEqual(summary.decision_source, NONE_DECISION_SOURCE); + assert.deepEqual(replay.actions, []); + assert.deepEqual( + await readJsonl(project.file("raw/action-attempts.jsonl"), "empty"), + [] + ); + assert.deepEqual( + await readJsonl(project.file("raw/action-results.jsonl"), "empty"), + [] + ); + assert.deepEqual( + (await readJson(project.file("manifest.json"))).world.decision_source, + NONE_DECISION_SOURCE + ); + const versions = (await readJson( + project.file("manifest.json") + )).contract_versions; + assert.equal("simfile.dynamics-run-action-source.v1" in versions, false); +}); + +test("A2 a declared silent source remains silence", async (t) => { + const project = await createActionSourceProject(t, { source: "silent" }); + const result = await captureCli(project.args({ ticks: 5 })); + assert.equal(result.code, 0); + assert.equal(result.stderr, ""); + const provenance = await readJson(project.file("provenance.json")); + const final = await readJson( + project.file("replay/final-session.json") + ); + assert.equal(final.next_tick, 5); + assert.deepEqual(provenance.action_source, SCRIPTED_SOURCE); + assert.deepEqual(provenance.decision_source, NONE_DECISION_SOURCE); + assert.deepEqual( + await readJsonl(project.file("raw/action-attempts.jsonl"), "empty"), + [] + ); + assert.deepEqual( + await readJsonl(project.file("raw/action-results.jsonl"), "empty"), + [] + ); + const versions = (await readJson( + project.file("manifest.json") + )).contract_versions; + for (const version of [ + "simfile.dynamics-run-action-source.v1", + "simfile.dynamics-run-action-ingress.v1", + "simfile.dynamics-run-action-result.v1" + ]) assert.equal(versions[version], version); +}); + +test("A3 zero, one, and many are distinguished by exact vectors", async (t) => { + for (const [name, actions] of [ + ["zero", []], + ["one", ["move:red"]], + ["many", ["move:red", "kick:blue", "move:blue"]] + ] as const) { + await t.test(name, async (subtest) => { + const project = await createActionSourceProject(subtest, { + actionsAtTickZero: actions + }); + const result = await captureCli(project.args({ ticks: 2 })); + assert.equal(result.code, 0, `${name}: ${result.stderr}`); + assert.equal(result.stderr, "", name); + const attempts = await readJsonl( + project.file("raw/action-attempts.jsonl"), + actions.length === 0 ? "empty" : "nonempty" + ); + const results = await readJsonl( + project.file("raw/action-results.jsonl"), + actions.length === 0 ? "empty" : "nonempty" + ); + assert.deepEqual( + attempts.map((entry) => entry.attempt.action), + actions + ); + assert.deepEqual( + attempts.map((entry) => entry.receipt.sequence), + actions.map((_, index) => index + 1) + ); + assert.deepEqual( + results.map((entry) => entry.result.action), + actions + ); + assert.deepEqual( + results.map((entry) => entry.result.sequence), + actions.map((_, index) => index + 1) + ); + assert.deepEqual( + results.map((entry) => entry.result.origin), + actions.map(() => "controller") + ); + const summary = await readJson(project.file("summary.json")); + assert.equal( + summary.decision_source.kind, + actions.length === 0 ? "none" : "controller" + ); + }); + } +}); + +test("A4 a hanging participant return never gates the clock", { + timeout: 60_000 +}, async (t) => { + const project = await createActionSourceProject(t, { + source: "returns-never-settling-promise" + }); + const result = await captureCli(project.args({ ticks: 40 })); + assert.equal(result.code, 0); + assert.equal(result.stderr, ""); + const final = await readJson( + project.file("replay/final-session.json") + ); + const steps = await readJsonl( + project.file("raw/steps.jsonl"), + "nonempty" + ); + assert.equal(final.next_tick, 40); + assert.deepEqual( + steps.map((step) => [step.from_tick, step.to_tick]), + Array.from({ length: 40 }, (_, tick) => [tick, tick + 1]) + ); + assert.deepEqual( + await readJsonl(project.file("raw/action-results.jsonl"), "empty"), + [] + ); +}); + +test("A5 sync submission followed by a hanging return still applies", { + timeout: 60_000 +}, async (t) => { + const project = await createActionSourceProject(t, { + source: "queues-one-then-returns-never" + }); + const result = await captureCli(project.args({ ticks: 3 })); + assert.equal(result.code, 0); + assert.equal(result.stderr, ""); + const results = await readJsonl( + project.file("raw/action-results.jsonl"), + "nonempty" + ); + assert.deepEqual( + results.map(({ result }) => [ + result.apply_tick, + result.sequence, + result.origin, + result.accepted + ]), + [[0, 1, "controller", true]] + ); + assert.equal( + (await readJson(project.file("summary.json"))).final_tick, + 3 + ); +}); + +test("A6 two action-bearing runs are byte-identical", async (t) => { + const project = await createActionSourceProject(t, { + actionsEveryTick: ["move:red", "move:blue"] + }); + const left = path.join(project.root, "left"); + const right = path.join(project.root, "right"); + const [leftResult, rightResult] = await Promise.all([ + captureCli(project.args({ + out: left, + runId: "action-source-double", + ticks: 40 + })), + captureCli(project.args({ + out: right, + runId: "action-source-double", + ticks: 40 + })) + ]); + assert.equal(leftResult.code, 0); + assert.equal(rightResult.code, 0); + assert.equal(leftResult.stderr, ""); + assert.equal(rightResult.stderr, ""); + const leftResults = await readJsonl( + project.file("raw/action-results.jsonl", left), + "nonempty" + ); + const rightResults = await readJsonl( + project.file("raw/action-results.jsonl", right), + "nonempty" + ); + assert.deepEqual( + leftResults.map(({ result }) => result.origin), + Array.from({ length: 80 }, () => "controller") + ); + assert.deepEqual(rightResults, leftResults); + assert.equal( + (await readJson(project.file("summary.json", left))) + .decision_source.kind, + "controller" + ); + await assertRecordsByteIdentical(left, right); +}); + +test("A7 action causes are exact and observe consumes the record", async (t) => { + const project = await createActionSourceProject(t, { + actionsAtTickZero: ["move:red"], + providerEventCausesAction: true + }); + assert.equal((await captureCli(project.args({ ticks: 2 }))).code, 0); + const ledger = await readJsonl( + project.file("raw/world/causal.jsonl"), + "nonempty" + ); + const ingress = ledger.find((event) => + event.type === "dynamics.action.queued"); + const result = ledger.find((event) => + event.type === "dynamics.action.applied"); + const step = ledger.find((event) => event.type === "dynamics.step" + && event.payload.from_tick === 0); + const effect = ledger.find((event) => event.type === "counter.moved"); + assert.ok(ingress && result && step && effect); + assert.deepEqual(result.cause_event_ids, [ingress.event_id]); + assert.deepEqual(step.cause_event_ids.slice(1), [result.event_id]); + assert.deepEqual( + effect.cause_event_ids, + [step.event_id, result.event_id] + ); + const observed = await captureCli(["observe", project.out, "--json"]); + assert.equal(observed.code, 0); + assert.equal(observed.stderr, ""); + const parsed = JSON.parse(observed.stdout) as any; + assert.deepEqual(parsed.causalParseErrors, []); + assert.equal( + parsed.artifactIntegrity.every((entry: any) => entry.ok === true), + true + ); + assert.deepEqual(parsed.report.chains.incomplete, []); + assert.deepEqual(parsed.report.failures, []); + assert.equal( + parsed.report.run_id, + (await readJson(project.file("summary.json"))).run_id + ); + await readFile(project.file("observe/report.json")); +}); + +test("H4 source cannot claim live provenance", async (t) => { + const project = await createActionSourceProject(t, { + declarationPatch: { live_acceptance: true } + }); + const result = await captureCli(project.args({ ticks: 1 })); + assert.equal(result.code, 1); + assert.equal( + result.stderr, + "dynamics run action source live_acceptance must be false\n" + ); + assert.equal(result.stdout, ""); + await assert.rejects(access(project.out)); +}); + +test("H5 source receives no raw session or action queue", async (t) => { + const project = await createActionSourceProject(t, { + source: "assert-context-exact-keys" + }); + const result = await captureCli(project.args({ ticks: 1 })); + assert.equal(result.code, 0); + assert.equal(result.stderr, ""); +}); + +test("H6 late async action is rejected and never appears", async (t) => { + const project = await createActionSourceProject(t, { + source: "queue-after-await" + }); + const result = await captureCli(project.args({ ticks: 3 })); + assert.equal(result.code, 0); + assert.equal(result.stderr, ""); + assert.deepEqual( + await readJsonl(project.file("raw/action-attempts.jsonl"), "empty"), + [] + ); + assert.deepEqual( + await readJsonl(project.file("raw/action-results.jsonl"), "empty"), + [] + ); + assert.deepEqual( + (await readJson(project.file("summary.json"))).decision_source, + NONE_DECISION_SOURCE + ); +}); + +test("H7 a synchronous source failure is not retried", async (t) => { + const project = await createActionSourceProject(t, { + source: "throw-with-call-counter" + }); + const result = await captureCli(project.args({ ticks: 4 })); + assert.equal(result.code, 1); + assert.equal(result.stderr, "injected source failure call 1\n"); + assert.equal(result.stdout, ""); + await assert.rejects(access(project.out)); +}); diff --git a/src/run/action-source.test-helper.ts b/src/run/action-source.test-helper.ts new file mode 100644 index 0000000..9cee320 --- /dev/null +++ b/src/run/action-source.test-helper.ts @@ -0,0 +1,377 @@ +import assert from "node:assert/strict"; +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { + mkdir, + mkdtemp, + readFile, + readdir, + rm, + writeFile +} from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import type { TestContext } from "node:test"; +import { fileURLToPath } from "node:url"; + +import { parseRunManifest } from "../observe/manifest.js"; + +export interface ActionSourceProjectOptions { + readonly actionsAtTickZero?: readonly string[]; + readonly actionsEveryTick?: readonly string[]; + readonly declarationPatch?: Readonly<{ + live_acceptance?: boolean; + }>; + readonly providerEventCausesAction?: boolean; + readonly source?: + | "absent" + | "assert-context-exact-keys" + | "queue-after-await" + | "queues-one-then-returns-never" + | "returns-never-settling-promise" + | "silent" + | "throw-with-call-counter"; +} + +export interface ActionSourceProject { + readonly out: string; + readonly root: string; + readonly simfilePath: string; + args(options: Readonly<{ + out?: string; + runId?: string; + ticks: number; + }>): string[]; + file(relative: string, out?: string): string; +} + +export const NONE_DECISION_SOURCE = Object.freeze({ + actors: Object.freeze([]) as readonly string[], + kind: "none", + model_decisions: false, + provenance: "none" +}); + +export const SCRIPTED_SOURCE = Object.freeze({ + id: "test-scripted-source", + live_acceptance: false, + participants: Object.freeze(["blue", "red"]), + provenance: "scripted", + version: "simfile.dynamics-run-action-source.v1" +}); + +const cliSource = fileURLToPath(new URL("../cli/index.ts", import.meta.url)); + +export const captureCli = async ( + args: readonly string[], + entry = cliSource +): Promise<{ code: number; stderr: string; stdout: string }> => + new Promise((resolve, reject) => { + const prefix = entry.endsWith(".ts") ? ["--import", "tsx"] : []; + const child = spawn(process.execPath, [...prefix, entry, ...args], { + stdio: ["ignore", "pipe", "pipe"] + }); + const stdout: Buffer[] = []; + const stderr: Buffer[] = []; + child.stdout.on("data", (chunk: Buffer) => stdout.push(chunk)); + child.stderr.on("data", (chunk: Buffer) => stderr.push(chunk)); + child.once("error", reject); + child.once("close", (code) => resolve({ + code: code ?? 1, + stderr: Buffer.concat(stderr).toString(), + stdout: Buffer.concat(stdout).toString() + })); + }); + +export const readJson = async (file: string): Promise => + JSON.parse(await readFile(file, "utf8")) as Value; + +export const readJsonl = async ( + file: string, + expected: "empty" | "nonempty" +): Promise => { + const text = await readFile(file, "utf8"); + if (expected === "empty") { + assert.equal(text, "", file); + return []; + } + assert.notEqual(text, "", file); + assert.equal(text.endsWith("\n"), true, file); + return text.trimEnd().split("\n").map((line) => JSON.parse(line) as Value); +}; + +const onTickSource = (options: ActionSourceProjectOptions): string => { + if (options.source === "returns-never-settling-promise") { + return "onTick() { return new Promise(() => {}); }"; + } + if (options.source === "queues-one-then-returns-never") { + return `onTick(context) { + if (context.next_tick === 0) queueLabel(context, "move:red", 0); + return new Promise(() => {}); + }`; + } + if (options.source === "assert-context-exact-keys") { + return `onTick(context) { + const keys = Reflect.ownKeys(context).sort(); + if (keys.join(",") !== "act,next_tick,observe,queueController,sim_time") { + throw new Error("source context exposed unexpected authority"); + } + }`; + } + if (options.source === "queue-after-await") { + return `async onTick(context) { + await undefined; + try { + queueLabel(context, "move:red", 0); + } catch (error) { + if (!(error instanceof Error) + || error.message !== "dynamics run action source tick is closed") { + throw error; + } + } + }`; + } + if (options.source === "throw-with-call-counter") { + return `onTick() { + calls += 1; + throw new Error("injected source failure call " + calls); + }`; + } + const atZero = JSON.stringify(options.actionsAtTickZero ?? []); + const every = JSON.stringify(options.actionsEveryTick ?? []); + return `onTick(context) { + const labels: readonly string[] = context.next_tick === 0 ? ${atZero} : []; + for (const [index, label] of labels.entries()) { + queueLabel(context, label, index); + } + for (const [index, label] of (${every} as readonly string[]).entries()) { + queueLabel(context, label, labels.length + index); + } + }`; +}; + +const moduleSource = (options: ActionSourceProjectOptions): string => { + const eventSource = options.providerEventCausesAction === true + ? `const events: DynamicsEventDraft[] = input.actions.map((command) => ({ + cause_action_sequences: [command.sequence], + kind: "counter.moved", + payload: { sequence: command.sequence }, + source: command.actor, + target: command.target + }));` + : "const events: DynamicsEventDraft[] = [];"; + const sourceAbsent = options.source === "absent"; + const live = options.declarationPatch?.live_acceptance ?? false; + const actionSource = sourceAbsent ? "" : ` +let calls = 0; +const queueLabel = ( + context: DynamicsRunActionSourceTick, + label: string, + index: number +) => { + const parts = label.split(":"); + const actor = parts.length > 1 ? parts[1] : "actor"; + return context.queueController({ + action: label, + actor: "object:" + actor, + controller_id: parts[0] + "-" + actor + "-" + index, + controller_version: "test-v1", + input: { index }, + policy: "default", + skill: parts[0], + target: "object:counter" + }); +}; +export const createDynamicsRunActionSource: DynamicsRunActionSourceFactory = +() => ({ + id: "test-scripted-source", + live_acceptance: ${String(live)} as false, + ${onTickSource(options)}, + participants: ["blue", "red"], + provenance: "scripted", + version: "simfile.dynamics-run-action-source.v1" +});`; + return ` +import type { + DynamicsEventDraft, + DynamicsProviderModule, + DynamicsRunActionSourceFactory, + DynamicsRunActionSourceTick +} from "simfile/dynamics"; + +export const createDynamicsProvider: + DynamicsProviderModule["createDynamicsProvider"] = () => { + let state = { value: 0 }; + return { + api_version: "simfile.dynamics-provider.v1", + id: "action-counter", + version: "1.0.0", + state_schema_version: "counter.v1", + initialize() { state = { value: 0 }; }, + observe() { return { channels: [] }; }, + restore(snapshot) { + if (snapshot === null || typeof snapshot !== "object" + || Array.isArray(snapshot) || typeof snapshot.value !== "number") { + throw new Error("invalid counter snapshot"); + } + state = { value: snapshot.value }; + }, + snapshot() { return { ...state }; }, + step(input) { + const action_results = input.actions.map((command) => ({ + accepted: true, + sequence: command.sequence + })); + state.value += action_results.length; + ${eventSource} + return { action_results, events, tick: input.tick }; + } + }; +}; +${actionSource} +`; +}; + +export const createActionSourceProject = async ( + testContext: TestContext, + options: ActionSourceProjectOptions = {} +): Promise => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-action-source-")); + testContext.after(() => rm(root, { force: true, recursive: true })); + const systems = path.join(root, "systems"); + const simfilePath = path.join(root, "Simfile"); + const out = path.join(root, "run"); + await mkdir(systems); + await writeFile(path.join(systems, "provider.ts"), moduleSource(options)); + await writeFile(simfilePath, ` +simfile_version: "0.1" +name: action-source-test +clock: + seed: action-source-seed + tick: 1ms + sim_per_tick: 0.25s +world: + id: counter + grants: + blue: + entity: entity:blue + senses: [] + affordances: [] + red: + entity: entity:red + senses: [] + affordances: [] +dynamics: + module: ./systems/provider.ts + config: {} +`); + return { + out, + root, + simfilePath, + args: ({ out: selectedOut = out, runId = "action-source-run", ticks }) => [ + "run", + simfilePath, + "--ticks", + String(ticks), + "--run-id", + runId, + "--out", + selectedOut, + "--clock", + "2026-01-02T03:04:05.000Z" + ], + file: (relative, selectedOut = out) => path.join(selectedOut, relative) + }; +}; + +export const literalRecordPaths = async (directory: string): Promise => { + const entries = await readdir(directory, { withFileTypes: true }); + const nested = await Promise.all(entries.map(async (entry) => { + const candidate = path.join(directory, entry.name); + if (!entry.isDirectory()) return [entry.name]; + return (await literalRecordPaths(candidate)) + .map((child) => path.join(entry.name, child)); + })); + return nested.flat().sort(); +}; + +export const assertManifestComplete = async ( + directory: string, + paths: readonly string[] +): Promise => { + const manifest = parseRunManifest( + await readJson(path.join(directory, "manifest.json")) + ); + assert.deepEqual( + manifest.artifacts.map((entry) => entry.path), + paths.filter((entry) => entry !== "manifest.json") + ); + for (const artifact of manifest.artifacts) { + assert.equal( + createHash("sha256") + .update(await readFile(path.join(directory, artifact.path))).digest("hex"), + artifact.sha256 + ); + } +}; + +const SEALED_PROVIDER_PATH = + /^dynamics\/sha256-[0-9a-f]{64}\/provider\.mjs$/u; +const EXPECTED_SEALED_PROVIDER_PATH = + "dynamics/sha256-<64 hex>/provider.mjs"; +const EXPECTED_ACTION_RECORD_PATHS = [ + "dynamics/build-receipt.json", + EXPECTED_SEALED_PROVIDER_PATH, + "manifest.json", + "provenance.json", + "raw/action-attempts.jsonl", + "raw/action-results.jsonl", + "raw/commitment-outcomes.jsonl", + // B192: the per-tick motion track, written by the action-bearing step loop + // too. An action-driven match that produced no frames would fail here. + "raw/frames.jsonl", + "raw/steps.jsonl", + "raw/world/action-refusals.jsonl", + "raw/world/causal.jsonl", + "raw/world/perception.jsonl", + "replay/action-stream.json", + "replay/final-session.json", + "replay/initial-session.json", + "summary.json", + "viewer-extensions.json" +]; + +const normalizeSealedProviderPath = ( + paths: readonly string[] +): string[] => paths.map((relative) => + SEALED_PROVIDER_PATH.test(relative) + ? EXPECTED_SEALED_PROVIDER_PATH + : relative +); + +export const assertRecordsByteIdentical = async ( + left: string, + right: string +): Promise => { + const leftPaths = await literalRecordPaths(left); + const rightPaths = await literalRecordPaths(right); + assert.deepEqual( + normalizeSealedProviderPath(leftPaths), + EXPECTED_ACTION_RECORD_PATHS + ); + assert.deepEqual( + normalizeSealedProviderPath(rightPaths), + EXPECTED_ACTION_RECORD_PATHS + ); + await assertManifestComplete(left, leftPaths); + await assertManifestComplete(right, rightPaths); + assert.deepEqual(rightPaths, leftPaths); + for (const relative of leftPaths) { + assert.deepEqual( + await readFile(path.join(right, relative)), + await readFile(path.join(left, relative)), + relative + ); + } +}; diff --git a/src/run/dynamics-run-action-causes.test.ts b/src/run/dynamics-run-action-causes.test.ts new file mode 100644 index 0000000..3631003 --- /dev/null +++ b/src/run/dynamics-run-action-causes.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict"; +import { access, mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { createDynamicsRunActionCauseIndex } from "./dynamics-run-action-causes.js"; + +test("uses a fixed-width scratch index for historical accepted action causes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-action-causes-")); + try { + const index = await createDynamicsRunActionCauseIndex(root, "run-one"); + await index.record(1, 7); + await index.record(4, 11); + assert.equal(await index.lookup(1), "simfile:run-one:7"); + assert.equal(await index.lookup(2), undefined); + assert.equal(await index.lookup(4), "simfile:run-one:11"); + await index.close(); + await assert.rejects(access(path.join(root, ".dynamics-action-causes"))); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); diff --git a/src/run/dynamics-run-action-causes.ts b/src/run/dynamics-run-action-causes.ts new file mode 100644 index 0000000..324ec89 --- /dev/null +++ b/src/run/dynamics-run-action-causes.ts @@ -0,0 +1,66 @@ +import { open, rm, type FileHandle } from "node:fs/promises"; +import { join } from "node:path"; + +import { createEventId } from "../ledger/stable.js"; + +export interface DynamicsRunActionCauseIndex { + close(): Promise; + lookup(actionSequence: number): Promise; + record(actionSequence: number, ledgerSequence: number): Promise; +} + +const ENTRY_BYTES = 8; +const entryPosition = (sequence: number): number => { + const position = (sequence - 1) * ENTRY_BYTES; + if (!Number.isSafeInteger(sequence) || sequence < 1 || !Number.isSafeInteger(position)) { + throw new Error("dynamics action cause sequence is outside the index range"); + } + return position; +}; + +const closeAndRemove = async (handle: FileHandle, path: string): Promise => { + const failures: unknown[] = []; + try { await handle.sync(); } catch (failure) { failures.push(failure); } + try { await handle.close(); } catch (failure) { failures.push(failure); } + try { await rm(path, { force: true }); } catch (failure) { failures.push(failure); } + if (failures.length > 0) { + throw new AggregateError(failures, "failed to remove dynamics action cause index"); + } +}; + +/** Fixed-width scratch index keeps historical causal lookup off the heap. */ +export const createDynamicsRunActionCauseIndex = async ( + stagingRoot: string, + runId: string +): Promise => { + const path = join(stagingRoot, ".dynamics-action-causes"); + const handle = await open(path, "wx+"); + let closed = false; + return { + close: async (): Promise => { + if (closed) return; + closed = true; + await closeAndRemove(handle, path); + }, + record: async (actionSequence, ledgerSequence): Promise => { + if (closed) throw new Error("dynamics action cause index is closed"); + if (!Number.isSafeInteger(ledgerSequence) || ledgerSequence < 1) { + throw new Error("dynamics ledger cause sequence is outside the index range"); + } + const value = Buffer.alloc(ENTRY_BYTES); + value.writeBigUInt64BE(BigInt(ledgerSequence)); + const result = await handle.write(value, 0, ENTRY_BYTES, entryPosition(actionSequence)); + if (result.bytesWritten !== ENTRY_BYTES) { + throw new Error("dynamics action cause index write was incomplete"); + } + }, + lookup: async (actionSequence): Promise => { + if (closed) throw new Error("dynamics action cause index is closed"); + const value = Buffer.alloc(ENTRY_BYTES); + const result = await handle.read(value, 0, ENTRY_BYTES, entryPosition(actionSequence)); + if (result.bytesRead !== ENTRY_BYTES) return undefined; + const ledgerSequence = Number(value.readBigUInt64BE()); + return ledgerSequence === 0 ? undefined : createEventId(runId, ledgerSequence); + } + }; +}; diff --git a/src/run/dynamics-run-action-source.test.ts b/src/run/dynamics-run-action-source.test.ts new file mode 100644 index 0000000..edf82f3 --- /dev/null +++ b/src/run/dynamics-run-action-source.test.ts @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + DYNAMICS_RUN_ACTION_SOURCE_VERSION, + type DynamicsRunActionSourceDeclaration, + type DynamicsRunControllerAction +} from "../dynamics/runActionSource.js"; +import type { DynamicsSession } from "../dynamics/session.js"; +import type { + DynamicsActionAttempt, + DynamicsActionIngressRecord, + DynamicsStepResult +} from "../dynamics/types.js"; +import { + createWorldRuntimeControllerAuthority, + isWorldRuntimeControllerAuthority, + type WorldRuntimeControllerAuthority +} from "../world/controllerAuthority.js"; +import { createDynamicsRunActionSourceHost } from "./dynamics-run-action-source.js"; + +interface SessionProbe { + readonly attempts: DynamicsActionAttempt[]; + readonly session: DynamicsSession; + step(): DynamicsStepResult; +} + +const createSessionProbe = (): SessionProbe => { + let nextTick = 0; + let nextSequence = 1; + let pending: Array = []; + const attempts: DynamicsActionAttempt[] = []; + const session = { + get nextTick() { + return nextTick; + }, + queueAction(value: unknown) { + const attempt = structuredClone(value) as DynamicsActionAttempt; + const sequence = nextSequence++; + attempts.push(attempt); + pending.push({ ...attempt, sequence }); + return { + act_id: attempt.act_id, + apply_tick: nextTick, + queued: true, + sequence + }; + } + } as unknown as DynamicsSession; + return { + attempts, + session, + step: (): DynamicsStepResult => { + const tick = nextTick++; + const action_results = pending.map((attempt) => ({ + accepted: true, + act_id: attempt.act_id, + action: attempt.action, + actor: attempt.actor, + apply_tick: tick, + origin: attempt.origin, + principal_id: attempt.principal_id, + sequence: attempt.sequence, + target: attempt.target + })); + pending = []; + return { action_results, events: [], tick }; + } + }; +}; + +const action = (index: number): DynamicsRunControllerAction => ({ + action: `move:${index}`, + actor: `actor:${index}`, + controller_id: `controller-${index}`, + controller_version: "test-v1", + input: { index }, + policy: "default", + skill: "move", + target: `target:${index}` +}); + +const source = ( + onTick: DynamicsRunActionSourceDeclaration["onTick"] +): DynamicsRunActionSourceDeclaration => ({ + id: "host-test", + live_acceptance: false, + onTick, + participants: ["blue", "red"], + provenance: "scripted", + version: DYNAMICS_RUN_ACTION_SOURCE_VERSION +}); + +test("zero, one, and many submissions use the controller authority in source order", () => { + for (const count of [0, 1, 3]) { + const probe = createSessionProbe(); + const host = createDynamicsRunActionSourceHost({ + session: probe.session, + source: source((context) => { + assert.deepEqual( + Reflect.ownKeys(context).sort(), + ["act", "next_tick", "observe", "queueController", "sim_time"] + ); + for (let index = 0; index < count; index += 1) { + context.queueController(action(index)); + } + }) + }); + host.notify(0); + const step = probe.step(); + host.settle(step); + assert.deepEqual( + probe.attempts.map((attempt) => attempt.action), + Array.from({ length: count }, (_, index) => `move:${index}`) + ); + assert.deepEqual( + probe.attempts.map((attempt) => attempt.origin), + Array.from({ length: count }, () => "controller") + ); + } +}); + +test("a hanging returned promise is ignored and does not gate settlement", () => { + const probe = createSessionProbe(); + const never = new Promise(() => {}); + const host = createDynamicsRunActionSourceHost({ + session: probe.session, + source: source(() => never) + }); + host.notify(0); + const step = probe.step(); + host.settle(step); + assert.equal(probe.session.nextTick, 1); + assert.deepEqual(probe.attempts, []); +}); + +test("a late async submission sees the closed tick and never enters ingress", async () => { + const probe = createSessionProbe(); + let late: Promise | undefined; + const host = createDynamicsRunActionSourceHost({ + session: probe.session, + source: source((context) => { + late = (async () => { + await undefined; + assert.throws( + () => context.queueController(action(0)), + /action source tick is closed/u + ); + })(); + return late; + }) + }); + host.notify(0); + await late; + assert.deepEqual(probe.attempts, []); +}); + +test("a synchronous source failure is surfaced once without retry", () => { + const probe = createSessionProbe(); + let calls = 0; + const host = createDynamicsRunActionSourceHost({ + session: probe.session, + source: source(() => { + calls += 1; + throw new Error(`injected source failure call ${calls}`); + }) + }); + assert.throws( + () => host.notify(0), + /injected source failure call 1/u + ); + assert.equal(calls, 1); +}); + +test("observation is declaration-scoped and unavailable without the B161 port", () => { + const probe = createSessionProbe(); + const host = createDynamicsRunActionSourceHost({ + session: probe.session, + source: source((context) => { + assert.throws( + () => context.observe("other", {}), + /participant other is not declared/u + ); + assert.throws( + () => context.observe("blue", {}), + /participant observation is unavailable/u + ); + }) + }); + host.notify(0); +}); + +test("participant hosts reject forged controllers while accepting read-only and issued hosts", () => { + const forgedProbe = createSessionProbe(); + const forged: WorldRuntimeControllerAuthority = Object.freeze({ + inspect: () => Object.freeze([]), + queue: () => Object.freeze({ + act_id: "forged", + apply_tick: 0, + queued: false + }), + settle: (step: DynamicsStepResult) => step + }); + const forgedParticipantHost = Object.freeze({ controller: forged }); + assert.equal(isWorldRuntimeControllerAuthority(forged), false); + assert.equal( + forgedParticipantHost !== undefined + && !isWorldRuntimeControllerAuthority(forgedParticipantHost.controller), + true + ); + assert.throws( + () => createDynamicsRunActionSourceHost({ + participantHost: forgedParticipantHost, + session: forgedProbe.session, + source: source(() => {}) + }), + /^Error: invalid dynamics run participant controller authority$/u + ); + + const readOnlyProbe = createSessionProbe(); + let observed = 0; + const readOnlyHost = createDynamicsRunActionSourceHost({ + participantHost: { + read: { + observe: (participant, request) => { + observed += 1; + assert.equal(participant, "blue"); + assert.deepEqual(request, { sense: "open" }); + return "observation"; + } + } + }, + session: readOnlyProbe.session, + source: source((context) => { + assert.equal( + context.observe("blue", { sense: "open" }), + "observation" + ); + }) + }); + readOnlyHost.notify(0); + assert.equal(observed, 1); + + const issuedProbe = createSessionProbe(); + const issued = createWorldRuntimeControllerAuthority({}, { + dynamics: issuedProbe.session, + operation: { + close: () => {}, + enter: () => {}, + leave: () => {} + } + }); + assert.equal(isWorldRuntimeControllerAuthority(issued), true); + assert.doesNotThrow(() => createDynamicsRunActionSourceHost({ + participantHost: { controller: issued }, + session: issuedProbe.session, + source: source(() => {}) + })); +}); diff --git a/src/run/dynamics-run-action-source.ts b/src/run/dynamics-run-action-source.ts new file mode 100644 index 0000000..7257805 --- /dev/null +++ b/src/run/dynamics-run-action-source.ts @@ -0,0 +1,149 @@ +import type { + DynamicsRunActionSourceDeclaration, + DynamicsRunActionSourceTick, + DynamicsRunControllerAction +} from "../dynamics/runActionSource.js"; +import type { DynamicsSession } from "../dynamics/session.js"; +import type { DynamicsStepResult } from "../dynamics/types.js"; +import { + createWorldRuntimeControllerAuthority, + isWorldRuntimeControllerAuthority, + type WorldRuntimeControllerAuthority +} from "../world/controllerAuthority.js"; +import type { WorldActionRefusalReadPort } from "../world/actionRefusalJournal.js"; +import type { WorldReadLedger } from "../world/ledger.js"; +import type { WorldActIngressReceipt } from "../world/actTypes.js"; +import { encodeWorldActEnvelope } from "../world/actEnvelope.js"; +import type { DynamicsRunWorldActRequest } from "../dynamics/runActionSource.js"; + +export interface DynamicsRunParticipantReadPort { + observe(participant: string, request: unknown): unknown; +} +export interface DynamicsRunParticipantActPort { + act(participant: string, envelope: Uint8Array): WorldActIngressReceipt; +} + +export interface DynamicsRunParticipantHost { + readonly controller?: WorldRuntimeControllerAuthority; + readonly read?: DynamicsRunParticipantReadPort; + readonly act?: DynamicsRunParticipantActPort; + readonly beginTick?: () => void; + readonly refusals?: WorldActionRefusalReadPort; + readonly readLedger?: WorldReadLedger; + readonly readLedgerPrincipals?: readonly string[]; + readonly step?: () => DynamicsStepResult; +} + +export interface DynamicsRunActionSourceHost { + notify(simTime: number): void; + step?(): DynamicsStepResult; + readonly stepSettles?: boolean; + settle(step: DynamicsStepResult): void; +} + +export interface CreateDynamicsRunActionSourceHostOptions { + readonly participantHost?: DynamicsRunParticipantHost; + readonly session: DynamicsSession; + readonly source: DynamicsRunActionSourceDeclaration; +} + +const createStandaloneController = ( + session: DynamicsSession +): WorldRuntimeControllerAuthority => { + const owner = {}; + let operating = false; + let closed = false; + const operation = Object.freeze({ + enter: (): void => { + if (closed) throw new Error("dynamics run controller authority is closed"); + if (operating) throw new Error("dynamics run controller authority reentry"); + operating = true; + }, + leave: (): void => { + operating = false; + }, + close: (): void => { + closed = true; + } + }); + return createWorldRuntimeControllerAuthority(owner, { + dynamics: session, + operation + }); +}; + +export const createDynamicsRunActionSourceHost = ( + options: CreateDynamicsRunActionSourceHostOptions +): DynamicsRunActionSourceHost => { + if ( + options.participantHost?.controller !== undefined + && !isWorldRuntimeControllerAuthority(options.participantHost.controller) + ) { + throw new Error("invalid dynamics run participant controller authority"); + } + const controller = options.participantHost?.controller + ?? createStandaloneController(options.session); + const participants = new Set(options.source.participants); + let open = false; + let actSequence = 0; + const assertOpen = (): void => { + if (!open) throw new Error("dynamics run action source tick is closed"); + }; + const notify = (simTime: number): void => { + open = true; + options.participantHost?.beginTick?.(); + const context: DynamicsRunActionSourceTick = Object.freeze({ + next_tick: options.session.nextTick, + sim_time: simTime, + observe: (participant: string, request: unknown): unknown => { + assertOpen(); + if (!participants.has(participant)) { + throw new Error( + `dynamics run action source participant ${participant} is not declared` + ); + } + if (options.participantHost?.read === undefined) { + throw new Error( + "dynamics run participant observation is unavailable" + ); + } + return options.participantHost.read.observe(participant, request); + }, + act: (participant: string, request: DynamicsRunWorldActRequest): WorldActIngressReceipt => { + assertOpen(); + if (!participants.has(participant)) { + throw new Error( + `dynamics run action source participant ${participant} is not declared` + ); + } + if (options.participantHost?.act === undefined) { + throw new Error("dynamics run participant action ingress is unavailable"); + } + const envelope = encodeWorldActEnvelope({ + request_id: `run:${options.session.nextTick}:${actSequence++}`, + affordance: request.affordance, + target: request.target, + input: request.input, + }); + return options.participantHost.act.act(participant, envelope); + }, + queueController: (action: DynamicsRunControllerAction) => { + assertOpen(); + return controller.queue(action); + } + }); + try { + options.source.onTick(context); + } finally { + open = false; + } + }; + return Object.freeze({ + notify, + step: options.participantHost?.step ?? (() => options.session.step()), + stepSettles: options.participantHost?.step !== undefined, + settle: (step: DynamicsStepResult): void => { + controller.settle(step); + } + }); +}; diff --git a/src/run/dynamics-run-action-ticks.test.ts b/src/run/dynamics-run-action-ticks.test.ts new file mode 100644 index 0000000..06fe7b8 --- /dev/null +++ b/src/run/dynamics-run-action-ticks.test.ts @@ -0,0 +1,442 @@ +import assert from "node:assert/strict"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import path from "node:path"; +import test from "node:test"; + +import { DYNAMICS_RUN_ACTION_SOURCE_VERSION } from "../dynamics/runActionSource.js"; +import type { DynamicsSession } from "../dynamics/session.js"; +import type { + DynamicsActionIngressEvidence, + DynamicsActionIngressRecord, + DynamicsActionQueueReceipt +} from "../dynamics/types.js"; +import type { LedgerEventEnvelope } from "../ledger/stable.js"; +import { createDynamicsRunDecisionEvidence } from "./dynamics-run-actions.js"; +import { writeDynamicsRunActionTicks } from "./dynamics-run-action-ticks.js"; +import type { DynamicsRunArtifactWriter } from "./dynamics-run-artifacts.js"; + +const evidence: DynamicsActionIngressEvidence = { + ordinal: 1, + record: { + attempt: { + act_id: "act-1", + action: "advance", + actor: "actor:one", + at_tick: 0, + input: { nested: { value: 1 } }, + origin: "controller", + principal_id: "controller:one", + target: "object:one" + }, + receipt: { act_id: "act-1", apply_tick: 0, queued: true, sequence: 1 } + } +}; + +type WriteActionTicksInput = Parameters[0]; + +const source = { + id: "source-one", + live_acceptance: false as const, + onTick: () => {}, + participants: [], + provenance: "scripted" as const, + version: DYNAMICS_RUN_ACTION_SOURCE_VERSION +}; + +const actionTicksInput = ( + root: string, + session: DynamicsSession, + writer: DynamicsRunArtifactWriter, + appendLedger: WriteActionTicksInput["appendLedger"] +): WriteActionTicksInput => ({ + appendLedger, + decisionEvidence: createDynamicsRunDecisionEvidence(), + dt: 1, + clock: () => new Date("2026-01-02T03:04:05.000Z"), + frames: { capture: async () => {} }, + host: { notify: () => {}, settle: () => {} }, + initialEvidenceOrdinal: 0, + previousStepEventId: "simfile:run-one:1", + runId: "run-one", + scope: "world:one", + seq: 2, + session, + source, + ticks: 1, + writer +}); + +const ingress = ( + ordinal: number, + actId: string, + receipt: Readonly<{ + code?: DynamicsActionQueueReceipt["code"]; + queued: boolean; + sequence?: number; + }> +): DynamicsActionIngressEvidence => ({ + ordinal, + record: { + attempt: { + act_id: actId, + action: "advance", + actor: `actor:${actId}`, + at_tick: 0, + input: {}, + origin: "controller", + principal_id: `controller:${actId}`, + target: `object:${actId}` + }, + receipt: { act_id: actId, apply_tick: 0, ...receipt } + } +}); + +const ingressSession = ( + ingressEvidence: readonly DynamicsActionIngressEvidence[], + acknowledgments: number[], + accepted = true +): DynamicsSession => { + let nextTick = 0; + return { + get nextTick() { return nextTick; }, + acknowledgeActionIngressEvidence: (ordinal: number) => { + acknowledgments.push(ordinal); + }, + readActionIngressEvidence: () => ingressEvidence, + step: () => { + const tick = nextTick; + nextTick += 1; + return { + action_results: ingressEvidence.flatMap(({ record }) => { + const sequence = record.receipt.sequence; + return record.receipt.queued && sequence !== undefined ? [{ + accepted, + act_id: record.attempt.act_id, + action: record.attempt.action, + actor: record.attempt.actor, + apply_tick: record.receipt.apply_tick, + origin: record.attempt.origin, + principal_id: record.attempt.principal_id, + sequence, + target: record.attempt.target + }] : []; + }), + events: [], + tick + }; + } + } as unknown as DynamicsSession; +}; + +test("does not acknowledge ingress evidence before every durable append succeeds", async () => { + for (const failurePoint of ["attempt", "causal"] as const) { + const root = await mkdtemp(path.join(tmpdir(), "simfile-action-tick-")); + const acknowledgments: number[] = []; + const session = { + get nextTick() { return 0; }, + acknowledgeActionIngressEvidence: (ordinal: number) => acknowledgments.push(ordinal), + readActionIngressEvidence: () => [evidence], + step: () => { throw new Error("step must not run after an append failure"); } + } as unknown as DynamicsSession; + const writer = { + stagingRealPath: root, + flush: async () => {}, + appendJsonl: async (relativePath: string) => { + if (failurePoint === "attempt" && relativePath === "raw/action-attempts.jsonl") { + throw new Error("injected attempt append failure"); + } + } + } as unknown as DynamicsRunArtifactWriter; + try { + await assert.rejects(writeDynamicsRunActionTicks(actionTicksInput( + root, + session, + writer, + async () => { + if (failurePoint === "causal") throw new Error("injected causal append failure"); + } + )), /injected .* append failure/u); + assert.deepEqual(acknowledgments, []); + } finally { + await rm(root, { force: true, recursive: true }); + } + } +}); + +test("does not acknowledge refusal evidence before its durable append succeeds", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-action-refusal-")); + const acknowledgments: number[] = []; + let nextTick = 0; + const session = { + get nextTick() { return nextTick; }, + acknowledgeActionIngressEvidence: () => {}, + readActionIngressEvidence: () => [], + step: () => { + const tick = nextTick; + nextTick += 1; + return { action_results: [], events: [], tick }; + } + } as unknown as DynamicsSession; + const writer = { + stagingRealPath: root, + appendJsonl: async () => {}, + flush: async () => { throw new Error("injected refusal flush failure"); } + } as unknown as DynamicsRunArtifactWriter; + const refusals = { + read: () => [{ + ordinal: 1, + refusal: { at_tick: 0, reason: "world_state_unstable" as const } + }], + acknowledge: (ordinal: number) => { acknowledgments.push(ordinal); } + }; + try { + await assert.rejects(writeDynamicsRunActionTicks({ + ...actionTicksInput(root, session, writer, async () => {}), + refusals + }), /injected refusal flush failure/u); + assert.deepEqual(acknowledgments, []); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test("records distinguishable dynamics ingress rejection causes", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-action-tick-")); + const ingressEvidence = [ + ingress(1, "queued", { queued: true, sequence: 1 }), + ingress(2, "wrong", { code: "wrong_tick", queued: false }), + ingress(3, "conflict", { code: "act_id_conflict", queued: false }) + ]; + const acknowledgments: number[] = []; + const ledgerEvents: LedgerEventEnvelope[] = []; + const session = ingressSession(ingressEvidence, acknowledgments); + const writer = { + stagingRealPath: root, + flush: async () => {}, + appendJsonl: async () => {} + } as unknown as DynamicsRunArtifactWriter; + try { + await writeDynamicsRunActionTicks(actionTicksInput( + root, session, writer, async (event) => { ledgerEvents.push(event); } + )); + const rejected = ledgerEvents.filter(({ kind }) => + kind === "dynamics.action.rejected_at_ingress"); + assert.equal(rejected.length, 2); + const payloads = rejected.map(({ payload }) => + payload as DynamicsActionIngressRecord); + const causes = payloads.map(({ receipt }) => receipt.code); + assert.equal(new Set(causes).size, 2); + assert.deepEqual(causes, ["wrong_tick", "act_id_conflict"]); + assert.deepEqual(payloads.map(({ attempt }) => attempt.act_id), [ + "wrong", "conflict" + ]); + const queued = ledgerEvents.filter(({ kind }) => + kind === "dynamics.action.queued"); + assert.equal(queued.length, 1); + assert.equal( + (queued[0]?.payload as DynamicsActionIngressRecord).attempt.act_id, + "queued" + ); + assert.deepEqual(acknowledgments, [1, 2, 3]); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test("rejects a causeless ingress receipt before durable output", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-action-tick-")); + const acknowledgments: number[] = []; + const appendedPaths: string[] = []; + const ledgerEvents: LedgerEventEnvelope[] = []; + const session = ingressSession([ + ingress(1, "causeless", { queued: false }) + ], acknowledgments); + const writer = { + stagingRealPath: root, + flush: async () => {}, + appendJsonl: async (relativePath: string) => { appendedPaths.push(relativePath); } + } as unknown as DynamicsRunArtifactWriter; + try { + await assert.rejects(writeDynamicsRunActionTicks(actionTicksInput( + root, session, writer, async (event) => { ledgerEvents.push(event); } + )), /dynamics action causeless: a rejected ingress receipt must name its cause/u); + assert.deepEqual(appendedPaths, []); + assert.deepEqual(ledgerEvents, []); + assert.deepEqual(acknowledgments, []); + } finally { + await rm(root, { force: true, recursive: true }); + } +}); + +test("keeps caller-authored strings out of the dynamics rejection cause", async () => { + const root = await mkdtemp(path.join(tmpdir(), "simfile-action-tick-")); + const marker = `"; + const schema = { + additionalProperties: false, + maxProperties: 2, + properties: { + declared: { maximum: 1, minimum: 0, type: "number" }, + }, + type: "object", + } as const as BoundedJsonSchemaNode; + + assert.throws( + () => validateBoundedJsonValue(schema, { declared: 1, [hostile]: "hostile-value" }, "value"), + (error: unknown) => { + const detail = readWorldSurfaceRejection(error); + assert.deepEqual(detail, { reason: "action_input_unknown_field" }); + assert.equal(detail?.fieldPath, undefined); + assert.equal(JSON.stringify(detail).includes(hostile), false); + return true; + }, + ); +}); diff --git a/src/world-surface/schema-value.ts b/src/world-surface/schema-value.ts new file mode 100644 index 0000000..453b70b --- /dev/null +++ b/src/world-surface/schema-value.ts @@ -0,0 +1,134 @@ +import { canonicalDynamicsJson } from "../dynamics/canonicalJson.js"; +import type { ReadonlyDynamicsJsonValue } from "../dynamics/types.js"; +import { ownDataValue } from "./own-data.js"; +import { + WorldSurfaceActionInputRejection, + type WorldActIngressRejectionReason, +} from "./rejection.js"; +import type { + BoundedJsonSchemaNode +} from "./types.js"; + +type JsonRecord = Readonly>; + +const reject = ( + message: string, + reason: WorldActIngressRejectionReason, + fieldPath?: string, +): never => { + throw new WorldSurfaceActionInputRejection(message, reason, fieldPath); +}; + +const propertyPath = (parent: string | undefined, property: string): string => + parent === undefined ? property : `${parent}.${property}`; +const indexPath = (parent: string | undefined, index: number): string | undefined => + parent === undefined ? undefined : `${parent}[${index}]`; + +const asObject = ( + value: ReadonlyDynamicsJsonValue, + path: string, + fieldPath: string | undefined, +): JsonRecord => { + if (value === null || Array.isArray(value) || typeof value !== "object") { + return reject(`${path} must be a JSON Schema object`, "action_input_wrong_type", fieldPath); + } + return value as JsonRecord; +}; + +export const validateBoundedJsonValue = ( + schema: BoundedJsonSchemaNode, + value: ReadonlyDynamicsJsonValue, + path: string, + checkConstants = true, + fieldPath?: string, +): void => { + if (schema.type === "null" && value !== null) { + return reject(`${path} must be null`, "action_input_wrong_type", fieldPath); + } + if (schema.type === "boolean" && typeof value !== "boolean") { + return reject(`${path} must be a boolean`, "action_input_wrong_type", fieldPath); + } + if ((schema.type === "number" || schema.type === "integer") + && (typeof value !== "number" || (schema.type === "integer" && !Number.isInteger(value)))) { + return reject(`${path} must be a ${schema.type}`, "action_input_wrong_type", fieldPath); + } + if (schema.type === "number" || schema.type === "integer") { + if ((value as number) < schema.minimum || (value as number) > schema.maximum) { + return reject(`${path} is outside the declared numeric bounds`, "action_input_out_of_bounds", fieldPath); + } + } else if (schema.type === "string") { + if (typeof value !== "string") { + return reject(`${path} must be a string`, "action_input_wrong_type", fieldPath); + } + const minLength = ownDataValue(schema, "minLength") ?? 0; + if (value.length < minLength || value.length > schema.maxLength) { + return reject(`${path} is outside the declared string bounds`, "action_input_out_of_bounds", fieldPath); + } + } else if (schema.type === "array") { + if (!Array.isArray(value)) { + return reject(`${path} must be an array`, "action_input_wrong_type", fieldPath); + } + const minItems = ownDataValue(schema, "minItems") ?? 0; + if (value.length < minItems || value.length > schema.maxItems) { + return reject(`${path} is outside the declared array bounds`, "action_input_out_of_bounds", fieldPath); + } + value.forEach((entry, index) => + validateBoundedJsonValue( + schema.items, + entry, + `${path}[${index}]`, + true, + indexPath(fieldPath, index), + )); + } else if (schema.type === "object") { + const record = asObject(value, path, fieldPath); + const keys = Object.keys(record); + const minProperties = ownDataValue(schema, "minProperties") ?? 0; + const maxProperties = ownDataValue(schema, "maxProperties") + ?? Object.keys(schema.properties).length; + // Preserve the original first-failure contract: object bounds deliberately + // precede required-field and unknown-property validation. + if (keys.length < minProperties || keys.length > maxProperties) { + return reject(`${path} is outside the declared object bounds`, "action_input_out_of_bounds", fieldPath); + } + const required = ownDataValue(schema, "required") ?? []; + for (const key of required) { + if (!Object.hasOwn(record, key)) { + return reject( + `${path}.${key} is required`, + "action_input_missing_field", + propertyPath(fieldPath, key), + ); + } + } + const schemaKeys = Object.keys(schema.properties); + for (const recordKey of keys) { + const schemaKey = schemaKeys.find((key) => key === recordKey); + if (schemaKey === undefined) { + return reject( + `${path}.${recordKey} is not an allowed property`, + "action_input_unknown_field", + ); + } + validateBoundedJsonValue( + ownDataValue(schema.properties, schemaKey)!, + ownDataValue(record, recordKey)!, + `${path}.${schemaKey}`, + true, + propertyPath(fieldPath, schemaKey), + ); + } + } + + if (!checkConstants) return; + const identity = canonicalDynamicsJson(value); + const constant = ownDataValue(schema, "const"); + if (constant !== undefined && canonicalDynamicsJson(constant) !== identity) { + return reject(`${path} does not equal the schema const`, "action_input_not_allowed_value", fieldPath); + } + const enumeration = ownDataValue(schema, "enum"); + if (enumeration !== undefined + && !enumeration.some((entry) => canonicalDynamicsJson(entry) === identity)) { + return reject(`${path} is not one of the schema enum values`, "action_input_not_allowed_value", fieldPath); + } +}; diff --git a/src/world-surface/schema.test.ts b/src/world-surface/schema.test.ts new file mode 100644 index 0000000..e48af74 --- /dev/null +++ b/src/world-surface/schema.test.ts @@ -0,0 +1,398 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import type { + DynamicsProviderObservation, + WorldSurfaceDefinition as DynamicsExportedWorldSurfaceDefinition +} from "../dynamics/index.js"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { + JSON_SCHEMA_2020_12, + parseBoundedJsonSchema, + parseBoundedJsonValue, + WORLD_SURFACE_API_VERSION, + type BoundedJsonSchema, + type BoundedJsonSchemaNode, + type WorldSenseProjectionInput, + type WorldSurfaceDefinition +} from "./index.js"; + +type TypeEqual = + (() => Value extends Left ? 1 : 2) extends + (() => Value extends Right ? 1 : 2) ? true : false; +type NestedNodeHasDialect = "$schema" extends keyof BoundedJsonSchemaNode ? true : false; +const nestedNodeHasDialect: NestedNodeHasDialect = false; +type CallbackObservation = WorldSenseProjectionInput["observation"]; +type CallbackChannel = CallbackObservation["channels"][number]; +const readonlyObservationProof: [ + TypeEqual, + CallbackObservation["channels"] extends unknown[] ? true : false, + TypeEqual>, + TypeEqual>, + TypeEqual>> +] = [false, false, true, true, true]; + +const rootDialectSchema = { + $schema: JSON_SCHEMA_2020_12, + type: "null" +} as const satisfies BoundedJsonSchema; + +const emptyObjectSchema = { + additionalProperties: false, + properties: {}, + type: "object" +} as const; + +const worldSurface = { + api_version: WORLD_SURFACE_API_VERSION, + entities: { + marker: { address: "entity:marker", dynamics_address: "object:marker" }, + observer: { address: "entity:observer", dynamics_address: "object:observer" } + }, + senses: { + "sense:local-state": { + dynamics_senses: ["sense:world.state"], + output: "simfile.numeric-observation.v1", + project: () => ({ channels: [] }) + } + }, + affordances: { + "affordance:adjust": { + available: () => true, + dynamics_action: "adjust", + input_schema: emptyObjectSchema, + lower: () => ({}), + rejection_codes: ["unavailable"], + target_selector: { kind: "fixed", targets: ["entity:marker"] } + } + }, + effects: { + "effect:marker-adjusted": { + dynamics_event: "world.marker_adjusted", + payload_schema: emptyObjectSchema + } + } +} as const satisfies WorldSurfaceDefinition; +const exportedWorldSurface: DynamicsExportedWorldSurfaceDefinition = worldSurface; + +const boundedActionSchema = () => ({ + $schema: JSON_SCHEMA_2020_12, + additionalProperties: false, + maxProperties: 3, + properties: { + direction: { + enum: ["left", "right"], + maxLength: 5, + type: "string" + }, + force: { + maximum: 1, + minimum: 0, + type: "number" + }, + samples: { + items: { + maximum: 10, + minimum: -10, + type: "integer" + }, + maxItems: 4, + type: "array" + } + }, + required: ["direction", "force"], + type: "object" +}); + +describe("bounded JSON Schema 2020-12 subset", () => { + it("exposes ergonomic world-surface authoring literals with a root dialect", () => { + assert.equal(rootDialectSchema.$schema, JSON_SCHEMA_2020_12); + assert.equal(nestedNodeHasDialect, false); + assert.deepEqual(readonlyObservationProof, [false, false, true, true, true]); + assert.equal(exportedWorldSurface.entities.marker.address, "entity:marker"); + assert.equal( + worldSurface.affordances["affordance:adjust"].target_selector.targets[0], + "entity:marker" + ); + }); + + it("accepts, clones, and freezes a closed bounded schema", () => { + const input = boundedActionSchema(); + const schema = parseBoundedJsonSchema(input); + + assert.notEqual(schema, input); + assert.equal(schema.$schema, JSON_SCHEMA_2020_12); + assert.equal(Object.isFrozen(schema), true); + assert.equal(Object.isFrozen((schema as { properties: object }).properties), true); + assert.deepEqual(JSON.parse(JSON.stringify(schema)), input); + }); + + it("validates and freezes values with numeric, string, array, object, and enum bounds", () => { + const schema = parseBoundedJsonSchema(boundedActionSchema()); + const source = { direction: "left", force: 0.5, samples: [-1, 2] }; + const value = parseBoundedJsonValue(schema, source); + + assert.deepEqual(JSON.parse(JSON.stringify(value)), source); + assert.notEqual(value, source); + assert.equal(Object.isFrozen(value), true); + assert.equal(Object.isFrozen((value as { samples: object }).samples), true); + + for (const invalid of [ + { direction: "up", force: 0.5 }, + { direction: "left" }, + { direction: "left", force: 2 }, + { direction: "left", force: 0.5, samples: [1, 2, 3, 4, 5] }, + { direction: "left", force: 0.5, extra: true } + ]) { + assert.throws(() => parseBoundedJsonValue(schema, invalid), /enum|required|bounds|allowed/u); + } + }); + + it("supports bounded const, boolean, and null leaves", () => { + const schema = parseBoundedJsonSchema({ + additionalProperties: false, + properties: { + enabled: { const: true, type: "boolean" }, + marker: { type: "null" } + }, + required: ["enabled", "marker"], + type: "object" + }); + assert.deepEqual( + JSON.parse(JSON.stringify(parseBoundedJsonValue(schema, { enabled: true, marker: null }))), + { enabled: true, marker: null } + ); + assert.throws( + () => parseBoundedJsonValue(schema, { enabled: false, marker: null }), + /schema const/u + ); + }); + + it("accepts the normative maximum string bound above the node ceiling", () => { + const schema = parseBoundedJsonSchema({ + maxLength: DYNAMICS_LIMITS.json_string_length, + type: "string" + }); + assert.equal(schema.type, "string"); + assert.equal(schema.maxLength, DYNAMICS_LIMITS.json_string_length); + }); + + it("rejects every reference and unsupported or behavioral keyword", () => { + for (const keyword of [ + { $ref: "https://example.test/schema" }, + { $ref: "#/$defs/local" }, + { $defs: {} }, + { default: 0 }, + { format: "uri" }, + { pattern: ".*" }, + { examples: [0] }, + { oneOf: [{ type: "null" }] } + ]) { + assert.throws( + () => parseBoundedJsonSchema({ + maximum: 1, + minimum: 0, + type: "number", + ...keyword + }), + /references|unsupported JSON Schema keyword/u + ); + } + assert.throws( + () => parseBoundedJsonSchema({ + additionalProperties: false, + properties: { + nested: { + $schema: JSON_SCHEMA_2020_12, + type: "null" + } + }, + type: "object" + }), + /may not change.*dialect/u + ); + }); + + it("rejects unbounded scalar, array, and object declarations", () => { + for (const schema of [ + { maximum: 1, type: "number" }, + { minimum: 0, type: "integer" }, + { type: "string" }, + { items: { type: "null" }, type: "array" }, + { maxItems: 1, type: "array" }, + { additionalProperties: true, properties: {}, type: "object" }, + { additionalProperties: false, type: "object" } + ]) { + assert.throws( + () => parseBoundedJsonSchema(schema), + /required|finite number|additionalProperties|properties/u + ); + } + }); + + it("rejects contradictory, non-finite, and excessive declared bounds", () => { + for (const schema of [ + { maximum: Infinity, minimum: 0, type: "number" }, + { maximum: 0, minimum: 1, type: "number" }, + { + maxLength: DYNAMICS_LIMITS.json_string_length + 1, + type: "string" + }, + { maxItems: DYNAMICS_LIMITS.json_nodes + 1, type: "array", items: { type: "null" } }, + { + additionalProperties: false, + maxProperties: DYNAMICS_LIMITS.json_nodes + 1, + properties: {}, + type: "object" + }, + { + additionalProperties: false, + maxProperties: 2, + properties: { only: { type: "null" } }, + type: "object" + }, + { maxLength: 1, minLength: 2, type: "string" }, + { maxItems: 1, minItems: 2, type: "array", items: { type: "null" } } + ]) { + assert.throws( + () => parseBoundedJsonSchema(schema), + /finite|exceed|within DYNAMICS_LIMITS|must not exceed/u + ); + } + assert.throws( + () => parseBoundedJsonSchema({ + maximum: 0.2, minimum: 0.1, type: "integer" + }), + /integer bounds must contain at least one integer/u + ); + }); + + it("rejects unsafe keys, undeclared required names, and duplicate enum values", () => { + const unsafeProperties = JSON.parse( + '{"type":"object","properties":{"__proto__":{"type":"null"}},"additionalProperties":false}' + ); + assert.throws(() => parseBoundedJsonSchema(unsafeProperties), /safe dynamics JSON key/u); + assert.throws( + () => parseBoundedJsonSchema({ + additionalProperties: false, + maxProperties: 0, + properties: { needed: { type: "null" } }, + required: ["needed"], + type: "object" + }), + /required exceeds maxProperties/u + ); + assert.throws( + () => parseBoundedJsonSchema({ + additionalProperties: false, + minProperties: 2, + properties: { only: { type: "null" } }, + type: "object" + }), + /minProperties exceeds/u + ); + assert.throws( + () => parseBoundedJsonSchema({ + additionalProperties: false, + maxProperties: 0, + minProperties: 1, + properties: { only: { type: "null" } }, + type: "object" + }), + /minProperties exceeds/u + ); + assert.throws( + () => parseBoundedJsonSchema({ + additionalProperties: false, + properties: {}, + required: ["constructor"], + type: "object" + }), + /unsafe property name/u + ); + assert.throws( + () => parseBoundedJsonSchema({ + additionalProperties: false, + properties: {}, + required: ["missing"], + type: "object" + }), + /undeclared property/u + ); + assert.throws( + () => parseBoundedJsonSchema({ + enum: ["same", "same"], + maxLength: 4, + type: "string" + }), + /enum values must be unique/u + ); + }); + + it("enforces schema depth, node, string, and cumulative code-unit limits", () => { + let deep: unknown = { type: "null" }; + for (let index = 0; index <= DYNAMICS_LIMITS.json_depth; index += 1) { + deep = { items: deep, maxItems: 1, type: "array" }; + } + assert.throws(() => parseBoundedJsonSchema(deep), /depth limit/u); + + const manyProperties = Object.fromEntries( + Array.from( + { length: DYNAMICS_LIMITS.json_nodes + 1 }, + (_, index) => [`p${index}`, { type: "null" }] + ) + ); + assert.throws( + () => parseBoundedJsonSchema({ + additionalProperties: false, + properties: manyProperties, + type: "object" + }), + /node limit/u + ); + assert.throws( + () => parseBoundedJsonSchema({ + description: "x".repeat(DYNAMICS_LIMITS.json_string_length + 1), + type: "null" + }), + /string limit/u + ); + const cumulative = Object.fromEntries( + Array.from( + { length: Math.floor(DYNAMICS_LIMITS.json_code_units / 1_000) + 1 }, + (_, index) => [ + `p${index}`, + { description: "x".repeat(1_000), type: "null" } + ] + ) + ); + assert.throws( + () => parseBoundedJsonSchema({ + additionalProperties: false, + properties: cumulative, + type: "object" + }), + /code-unit limit/u + ); + }); + + it("rejects hostile schema and value shapes before reading accessors", () => { + const accessor = Object.defineProperty({}, "type", { + enumerable: true, + get: () => { throw new Error("ACCESSOR_RAN"); } + }); + assert.throws( + () => parseBoundedJsonSchema(accessor), + /enumerable data value/u + ); + + const schema = parseBoundedJsonSchema({ + items: { type: "null" }, + maxItems: 2, + type: "array" + }); + const sparse: unknown[] = []; + sparse[1] = null; + assert.throws(() => parseBoundedJsonValue(schema, sparse), /sparse arrays/u); + }); +}); diff --git a/src/world-surface/schema.ts b/src/world-surface/schema.ts new file mode 100644 index 0000000..44fc729 --- /dev/null +++ b/src/world-surface/schema.ts @@ -0,0 +1,361 @@ +import { canonicalDynamicsJson } from "../dynamics/canonicalJson.js"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import type { ReadonlyDynamicsJsonValue } from "../dynamics/types.js"; +import { + cloneStableDynamicsJson, + deepFreezeOwnData, + defineOwnData, + nullPrototypeRecord, + ownDataValue +} from "./own-data.js"; +import { validateBoundedJsonValue } from "./schema-value.js"; +import { + JSON_SCHEMA_2020_12, + type BoundedArraySchema, + type BoundedBooleanSchema, + type BoundedJsonSchema, + type BoundedJsonSchemaNode, + type BoundedNullSchema, + type BoundedNumberSchema, + type BoundedObjectSchema, + type BoundedStringSchema +} from "./types.js"; + +const UNSAFE_KEYS = new Set(["__proto__", "constructor", "prototype"]); +const COMMON_KEYWORDS = [ + "$schema", + "const", + "description", + "enum", + "title", + "type" +] as const; +const TYPE_KEYWORDS = { + array: ["items", "maxItems", "minItems"], + boolean: [], + integer: ["maximum", "minimum"], + null: [], + number: ["maximum", "minimum"], + object: [ + "additionalProperties", + "maxProperties", + "minProperties", + "properties", + "required" + ], + string: ["maxLength", "minLength"] +} as const; + +type SchemaType = keyof typeof TYPE_KEYWORDS; +type JsonRecord = Readonly>; + +const asObject = (value: unknown, path: string): JsonRecord => { + if (value === null || Array.isArray(value) || typeof value !== "object") { + throw new TypeError(`${path} must be a JSON Schema object`); + } + return value as JsonRecord; +}; + +const requiredType = (record: JsonRecord, path: string): SchemaType => { + const type = ownDataValue(record, "type"); + if (typeof type !== "string" || !Object.hasOwn(TYPE_KEYWORDS, type)) { + throw new TypeError(`${path}.type must be one supported singular JSON type`); + } + return type as SchemaType; +}; + +const rejectUnsupportedKeywords = ( + record: JsonRecord, + type: SchemaType, + path: string, + root: boolean +): void => { + const allowed = new Set([...COMMON_KEYWORDS, ...TYPE_KEYWORDS[type]]); + for (const key of Object.keys(record)) { + if (key === "$ref") { + throw new TypeError(`${path} must not contain JSON Schema references`); + } + if (!allowed.has(key)) { + throw new TypeError(`${path} contains unsupported JSON Schema keyword ${JSON.stringify(key)}`); + } + if (key === "$schema" && !root) { + throw new TypeError(`${path} may not change the JSON Schema dialect`); + } + } +}; + +const optionalText = ( + record: JsonRecord, + key: "description" | "title", + path: string +): string | undefined => { + const value = ownDataValue(record, key); + if (value === undefined) return undefined; + if (typeof value !== "string") throw new TypeError(`${path}.${key} must be a string`); + return value; +}; + +const boundedCount = ( + value: ReadonlyDynamicsJsonValue | undefined, + path: string, + required: boolean, + ceiling: number = DYNAMICS_LIMITS.json_nodes +): number | undefined => { + if (value === undefined) { + if (required) throw new TypeError(`${path} is required to bound this schema`); + return undefined; + } + if (!Number.isSafeInteger(value) || (value as number) < 0 + || (value as number) > ceiling) { + throw new TypeError(`${path} must be a non-negative safe integer within DYNAMICS_LIMITS`); + } + return value as number; +}; + +const finiteNumber = ( + value: ReadonlyDynamicsJsonValue | undefined, + path: string +): number => { + if (typeof value !== "number" || !Number.isFinite(value)) { + throw new TypeError(`${path} must be a finite number`); + } + return value; +}; + +const parseCommon = ( + record: JsonRecord, + path: string, + root: boolean +): { + readonly $schema?: typeof JSON_SCHEMA_2020_12; + readonly const?: ReadonlyDynamicsJsonValue; + readonly description?: string; + readonly enum?: readonly ReadonlyDynamicsJsonValue[]; + readonly title?: string; +} => { + const dialect = ownDataValue(record, "$schema"); + const constant = ownDataValue(record, "const"); + const enumValue = ownDataValue(record, "enum"); + const description = optionalText(record, "description", path); + const title = optionalText(record, "title", path); + if (dialect !== undefined) { + if (!root || dialect !== JSON_SCHEMA_2020_12) { + throw new TypeError(`${path} supports only the JSON Schema 2020-12 dialect`); + } + } + let enumeration: readonly ReadonlyDynamicsJsonValue[] | undefined; + if (enumValue !== undefined) { + if (!Array.isArray(enumValue) || enumValue.length === 0) { + throw new TypeError(`${path}.enum must be a non-empty array`); + } + const identities = enumValue.map((value) => canonicalDynamicsJson(value)); + if (new Set(identities).size !== identities.length) { + throw new TypeError(`${path}.enum values must be unique`); + } + enumeration = enumValue; + } + return { + ...(dialect === undefined ? {} : { $schema: JSON_SCHEMA_2020_12 }), + ...(constant === undefined ? {} : { const: constant }), + ...(description === undefined ? {} : { description }), + ...(enumeration === undefined ? {} : { enum: enumeration }), + ...(title === undefined ? {} : { title }) + }; +}; + +const parseSchema = ( + value: ReadonlyDynamicsJsonValue, + path: string, + root: boolean +): BoundedJsonSchemaNode => { + const record = asObject(value, path); + if (Object.hasOwn(record, "$ref")) { + throw new TypeError(`${path} must not contain JSON Schema references`); + } + const type = requiredType(record, path); + rejectUnsupportedKeywords(record, type, path, root); + const common = parseCommon(record, path, root); + let schema: BoundedJsonSchemaNode; + + if (type === "null") { + schema = { ...common, type } satisfies BoundedNullSchema; + } else if (type === "boolean") { + schema = { ...common, type } satisfies BoundedBooleanSchema; + } else if (type === "number" || type === "integer") { + const minimum = finiteNumber( + ownDataValue(record, "minimum"), + `${path}.minimum` + ); + const maximum = finiteNumber( + ownDataValue(record, "maximum"), + `${path}.maximum` + ); + if (minimum > maximum) throw new TypeError(`${path}.minimum must not exceed maximum`); + if (type === "integer" && Math.ceil(minimum) > Math.floor(maximum)) { + throw new TypeError(`${path} integer bounds must contain at least one integer`); + } + schema = { ...common, maximum, minimum, type } satisfies BoundedNumberSchema; + } else if (type === "string") { + const minLength = boundedCount( + ownDataValue(record, "minLength"), + `${path}.minLength`, + false, + DYNAMICS_LIMITS.json_string_length + ); + const maxLength = boundedCount( + ownDataValue(record, "maxLength"), + `${path}.maxLength`, + true, + DYNAMICS_LIMITS.json_string_length + )!; + if (maxLength > DYNAMICS_LIMITS.json_string_length) { + throw new TypeError(`${path}.maxLength exceeds DYNAMICS_LIMITS.json_string_length`); + } + if (minLength !== undefined && minLength > maxLength) { + throw new TypeError(`${path}.minLength must not exceed maxLength`); + } + schema = { + ...common, + maxLength, + ...(minLength === undefined ? {} : { minLength }), + type + } satisfies BoundedStringSchema; + } else if (type === "array") { + const items = ownDataValue(record, "items"); + if (items === undefined) throw new TypeError(`${path}.items is required`); + const minItems = boundedCount( + ownDataValue(record, "minItems"), + `${path}.minItems`, + false + ); + const maxItems = boundedCount( + ownDataValue(record, "maxItems"), + `${path}.maxItems`, + true + )!; + if (minItems !== undefined && minItems > maxItems) { + throw new TypeError(`${path}.minItems must not exceed maxItems`); + } + schema = { + ...common, + items: parseSchema(items, `${path}.items`, false), + maxItems, + ...(minItems === undefined ? {} : { minItems }), + type + } satisfies BoundedArraySchema; + } else { + const properties = asObject( + ownDataValue(record, "properties"), + `${path}.properties` + ); + if (ownDataValue(record, "additionalProperties") !== false) { + throw new TypeError(`${path}.additionalProperties must be false`); + } + const parsedProperties = nullPrototypeRecord([]); + for (const [key, child] of Object.entries(properties)) { + defineOwnData( + parsedProperties, + key, + parseSchema(child, `${path}.properties.${key}`, false) + ); + } + const required = parseRequired( + ownDataValue(record, "required"), + parsedProperties, + `${path}.required` + ); + const minProperties = boundedCount( + ownDataValue(record, "minProperties"), + `${path}.minProperties`, + false + ); + const maxProperties = boundedCount( + ownDataValue(record, "maxProperties"), + `${path}.maxProperties`, + false + ); + const propertyCount = Object.keys(parsedProperties).length; + if (maxProperties !== undefined && maxProperties > propertyCount) { + throw new TypeError(`${path}.maxProperties exceeds its closed property count`); + } + const effectiveMaximum = maxProperties ?? propertyCount; + if (minProperties !== undefined && minProperties > effectiveMaximum) { + throw new TypeError(`${path}.minProperties exceeds its closed property bound`); + } + if (required !== undefined && required.length > effectiveMaximum) { + throw new TypeError(`${path}.required exceeds maxProperties`); + } + schema = { + ...common, + additionalProperties: false, + ...(maxProperties === undefined ? {} : { maxProperties }), + ...(minProperties === undefined ? {} : { minProperties }), + properties: parsedProperties, + ...(required === undefined ? {} : { required }), + type + } satisfies BoundedObjectSchema; + } + + validateSchemaConstants(schema, path); + return schema; +}; + +const parseRequired = ( + value: ReadonlyDynamicsJsonValue | undefined, + properties: Readonly>, + path: string +): readonly string[] | undefined => { + if (value === undefined) return undefined; + if (!Array.isArray(value) || value.some((entry) => typeof entry !== "string")) { + throw new TypeError(`${path} must be an array of property names`); + } + const names = value as string[]; + if (new Set(names).size !== names.length) throw new TypeError(`${path} must be unique`); + for (const name of names) { + if (UNSAFE_KEYS.has(name)) throw new TypeError(`${path} contains an unsafe property name`); + if (!Object.hasOwn(properties, name)) { + throw new TypeError(`${path} names undeclared property ${JSON.stringify(name)}`); + } + } + return names; +}; + +const validateSchemaConstants = (schema: BoundedJsonSchemaNode, path: string): void => { + const constant = ownDataValue(schema, "const"); + const enumeration = + ownDataValue(schema, "enum"); + if (constant !== undefined) { + validateBoundedJsonValue(schema, constant, `${path}.const`, false); + } + for (const [index, value] of (enumeration ?? []).entries()) { + validateBoundedJsonValue(schema, value, `${path}.enum[${index}]`, false); + } + if (constant !== undefined && enumeration !== undefined) { + const identity = canonicalDynamicsJson(constant); + if (!enumeration.some((value) => canonicalDynamicsJson(value) === identity)) { + throw new TypeError(`${path}.const must be present in enum`); + } + } +}; + +export const parseBoundedJsonSchema = (input: unknown): BoundedJsonSchema => { + const parsed = parseSchema( + cloneStableDynamicsJson(input, "schema"), + "schema", + true + ); + return deepFreezeOwnData( + cloneStableDynamicsJson(parsed, "checked schema") + ) as unknown as BoundedJsonSchema; +}; + +export const parseBoundedJsonValue = ( + schema: BoundedJsonSchema, + input: unknown, + path = "value" +): ReadonlyDynamicsJsonValue => { + const checkedSchema = parseBoundedJsonSchema(schema); + const value = cloneStableDynamicsJson(input, path); + validateBoundedJsonValue(checkedSchema, value, path); + return deepFreezeOwnData(value); +}; diff --git a/src/world-surface/synchrony-hostile.test.ts b/src/world-surface/synchrony-hostile.test.ts new file mode 100644 index 0000000..2fc7c8b --- /dev/null +++ b/src/world-surface/synchrony-hostile.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + localRef, + validWorldSurface, + worldContext +} from "./definition.test-helper.js"; +import { parseWorldSurfaceDefinition } from "./index.js"; + +const invokeAvailability = (available: () => unknown): boolean => { + const registry = parseWorldSurfaceDefinition(validWorldSurface({ available })); + return registry.isAffordanceAvailable( + localRef("affordance:kick"), + worldContext() + ); +}; + +describe("world surface synchronous return guard", () => { + it("rejects constructor accessors without reading them", () => { + for (const inherited of [false, true]) { + let reads = 0; + const callback = () => true; + const holder = inherited + ? Object.create(Object.getPrototypeOf(callback)) as object + : callback; + Object.defineProperty(holder, "constructor", { + configurable: true, + get: () => { + reads += 1; + return Function; + } + }); + if (inherited) Object.setPrototypeOf(callback, holder); + assert.throws( + () => parseWorldSurfaceDefinition(validWorldSurface({ + available: callback + })), + /constructor must not be an accessor/u + ); + assert.equal(reads, 0); + } + }); + + it("still rejects an ordinary async declaration", () => { + assert.throws( + () => parseWorldSurfaceDefinition(validWorldSurface({ + available: async () => true + })), + /must be synchronous/u + ); + }); + + it("does not inspect a constructor accessor shadowed by data", () => { + let reads = 0; + const deepPrototype = {}; + Object.defineProperty(deepPrototype, "constructor", { + get: () => { + reads += 1; + return Function; + } + }); + const shadow = Object.create(deepPrototype) as object; + Object.defineProperty(shadow, "constructor", { value: Function }); + const callback = () => true; + Object.setPrototypeOf(callback, shadow); + assert.doesNotThrow(() => + parseWorldSurfaceDefinition(validWorldSurface({ available: callback }))); + assert.equal(reads, 0); + }); + + it("does not read an Object.prototype accessor shadowed by own data", () => { + const original = Object.getOwnPropertyDescriptor( + Object.prototype, + "constructor" + ); + assert.ok(original); + let reads = 0; + const callback = () => true; + Object.defineProperty(callback, "constructor", { value: Function }); + try { + Object.defineProperty(Object.prototype, "constructor", { + configurable: true, + get: () => { + reads += 1; + return Object; + } + }); + parseWorldSurfaceDefinition(validWorldSurface({ available: callback })); + } finally { + Object.defineProperty(Object.prototype, "constructor", original); + } + assert.equal(reads, 0); + }); + + it("rejects an own then getter without reading it", () => { + let reads = 0; + assert.throws(() => invokeAvailability(() => { + const result = {}; + Object.defineProperty(result, "then", { + enumerable: true, + get: () => { + reads += 1; + return () => undefined; + } + }); + return result; + }), /then must not be an accessor/u); + assert.equal(reads, 0); + }); + + it("rejects an inherited then getter without reading it", () => { + let reads = 0; + const prototype = {}; + Object.defineProperty(prototype, "then", { + get: () => { + reads += 1; + return () => undefined; + } + }); + assert.throws( + () => invokeAvailability(() => Object.create(prototype) as object), + /then must not be an accessor/u + ); + assert.equal(reads, 0); + }); + + it("rejects a function-valued data thenable", () => { + assert.throws( + () => invokeAvailability(() => ({ then: () => undefined })), + /must return synchronously/u + ); + }); + + it("rejects a genuine Promise through Promise.prototype.then", () => { + assert.throws( + () => invokeAvailability(() => Promise.resolve(true)), + /must return synchronously/u + ); + }); +}); diff --git a/src/world-surface/synchrony.ts b/src/world-surface/synchrony.ts new file mode 100644 index 0000000..f31bf92 --- /dev/null +++ b/src/world-surface/synchrony.ts @@ -0,0 +1,102 @@ +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; + +const ASYNC_FUNCTION_PROTOTYPE = Object.getPrototypeOf(async function () {}); + +const synchronousError = (path: string): TypeError => + new TypeError(`${path} must return synchronously`); + +const assertSynchronousFunctionPrototype = ( + value: Function, + path: string +): void => { + let candidate: object | null = value; + const visited = new Set(); + let constructorResolved = false; + for (let depth = 0; candidate !== null; depth += 1) { + if (depth > DYNAMICS_LIMITS.json_depth || visited.has(candidate)) { + throw new TypeError(`${path} has an invalid or excessive prototype chain`); + } + visited.add(candidate); + if (candidate === ASYNC_FUNCTION_PROTOTYPE) { + throw new TypeError(`${path} must be synchronous`); + } + if (!constructorResolved) { + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(candidate, "constructor"); + } catch (error) { + throw new TypeError(`${path}.constructor descriptor cannot be inspected`, { + cause: error + }); + } + if (descriptor && !("value" in descriptor)) { + throw new TypeError(`${path}.constructor must not be an accessor`); + } + if (descriptor) constructorResolved = true; + } + try { + candidate = Object.getPrototypeOf(candidate); + } catch (error) { + throw new TypeError(`${path} prototype cannot be inspected`, { + cause: error + }); + } + } +}; + +const assertNotPromiseLike = (value: unknown, path: string): void => { + if ((typeof value !== "object" || value === null) && typeof value !== "function") { + return; + } + let candidate: object | null = value; + const visited = new Set(); + for (let depth = 0; candidate !== null; depth += 1) { + if (depth > DYNAMICS_LIMITS.json_depth || visited.has(candidate)) { + throw new TypeError(`${path} has an invalid or excessive prototype chain`); + } + visited.add(candidate); + let descriptor: PropertyDescriptor | undefined; + try { + descriptor = Object.getOwnPropertyDescriptor(candidate, "then"); + } catch (error) { + throw new TypeError(`${path}.then descriptor cannot be inspected`, { + cause: error + }); + } + if (descriptor) { + if (!("value" in descriptor)) { + throw new TypeError(`${path}.then must not be an accessor`); + } + if (typeof descriptor.value === "function") throw synchronousError(path); + return; + } + try { + candidate = Object.getPrototypeOf(candidate); + } catch (error) { + throw new TypeError(`${path} prototype cannot be inspected`, { + cause: error + }); + } + } +}; + +export const callWorldSurfaceSynchronous = ( + callback: (...args: never[]) => Result, + argument: unknown, + path: string +): Result => { + const result = callback(argument as never); + assertNotPromiseLike(result, path); + return result; +}; + +export const parseWorldSurfaceSynchronousFunction = < + Callback extends (...args: never[]) => unknown +>( + value: unknown, + path: string +): Callback => { + if (typeof value !== "function") throw new TypeError(`${path} must be a function`); + assertSynchronousFunctionPrototype(value, path); + return value as Callback; +}; diff --git a/src/world-surface/types.ts b/src/world-surface/types.ts new file mode 100644 index 0000000..191dbe0 --- /dev/null +++ b/src/world-surface/types.ts @@ -0,0 +1,228 @@ +import type { + DynamicsJsonObject, + DynamicsProvider, + DynamicsProviderObservation, + ReadonlyDynamicsJsonObject, + ReadonlyDynamicsJsonValue +} from "../dynamics/types.js"; +import type { LocalResourceReference } from "../world/addresses.js"; + +export const WORLD_SURFACE_API_VERSION = "simfile.world-surface.v1" as const; +export const JSON_SCHEMA_2020_12 = "https://json-schema.org/draft/2020-12/schema" as const; + +interface BoundedSchemaAnnotations { + readonly description?: string; + readonly title?: string; +} + +interface BoundedSchemaValueConstraints { + readonly const?: ReadonlyDynamicsJsonValue; + readonly enum?: readonly ReadonlyDynamicsJsonValue[]; +} + +export interface BoundedNullSchema + extends BoundedSchemaAnnotations, BoundedSchemaValueConstraints { + readonly type: "null"; +} + +export interface BoundedBooleanSchema + extends BoundedSchemaAnnotations, BoundedSchemaValueConstraints { + readonly type: "boolean"; +} + +export interface BoundedNumberSchema + extends BoundedSchemaAnnotations, BoundedSchemaValueConstraints { + readonly maximum: number; + readonly minimum: number; + readonly type: "number" | "integer"; +} + +export interface BoundedStringSchema + extends BoundedSchemaAnnotations, BoundedSchemaValueConstraints { + readonly maxLength: number; + readonly minLength?: number; + readonly type: "string"; +} + +export interface BoundedArraySchema + extends BoundedSchemaAnnotations, BoundedSchemaValueConstraints { + readonly items: BoundedJsonSchemaNode; + readonly maxItems: number; + readonly minItems?: number; + readonly type: "array"; +} + +export interface BoundedObjectSchema + extends BoundedSchemaAnnotations, BoundedSchemaValueConstraints { + readonly additionalProperties: false; + readonly maxProperties?: number; + readonly minProperties?: number; + readonly properties: Readonly>; + readonly required?: readonly string[]; + readonly type: "object"; +} + +export type BoundedJsonSchemaNode = + | BoundedNullSchema + | BoundedBooleanSchema + | BoundedNumberSchema + | BoundedStringSchema + | BoundedArraySchema + | BoundedObjectSchema; + +export type BoundedJsonSchema = BoundedJsonSchemaNode & { + readonly $schema?: typeof JSON_SCHEMA_2020_12; +}; + +export type BoundedObjectJsonSchema = BoundedObjectSchema & { + readonly $schema?: typeof JSON_SCHEMA_2020_12; +}; + +export type WorldEntityReference = `entity:${string}`; +export type WorldSenseReference = `sense:${string}`; +export type WorldAffordanceReference = `affordance:${string}`; +export type WorldEffectReference = `effect:${string}`; + +export interface WorldEntityDefinition { + readonly address: WorldEntityReference; + readonly dynamics_address: string; +} + +export interface ReadonlyWorldSurfaceObservationChannel { + readonly components: Readonly>; + readonly frame?: string; + readonly sense_address: string; + readonly subject_address: string; + readonly unit?: string; +} + +export interface ReadonlyWorldSurfaceObservation { + readonly channels: readonly ReadonlyWorldSurfaceObservationChannel[]; +} + +export interface WorldSenseProjectionInput { + readonly holder: LocalResourceReference; + readonly observation: ReadonlyWorldSurfaceObservation; +} + +export interface WorldSenseDefinition { + readonly dynamics_senses: readonly string[]; + readonly output: "simfile.numeric-observation.v1"; + project(input: WorldSenseProjectionInput): DynamicsProviderObservation; +} + +export type WorldAffordanceTargetSelector = + | { readonly kind: "holder" } + | { + readonly kind: "fixed"; + readonly targets: readonly WorldEntityReference[]; + }; + +export interface WorldAffordanceContext { + readonly holder: LocalResourceReference; + readonly observation: ReadonlyWorldSurfaceObservation; + readonly target: LocalResourceReference; +} + +export interface WorldAffordanceLoweringInput extends WorldAffordanceContext { + readonly input: ReadonlyDynamicsJsonObject; +} + +export interface WorldMechanicsResult { + readonly accepted: boolean; + readonly code?: string; + readonly message?: string; +} + +export interface WorldAffordanceDefinition { + readonly dynamics_action: string; + readonly input_schema: BoundedObjectJsonSchema; + readonly rejection_codes: readonly string[]; + readonly target_selector: WorldAffordanceTargetSelector; + available(input: WorldAffordanceContext): boolean; + lower(input: WorldAffordanceLoweringInput): DynamicsJsonObject; + project_result?(result: WorldMechanicsResult): DynamicsJsonObject; +} + +export interface WorldEffectDefinition { + readonly dynamics_event: string; + readonly payload_schema: BoundedObjectJsonSchema; +} + +export interface WorldSurfaceDefinition { + readonly api_version: typeof WORLD_SURFACE_API_VERSION; + readonly affordances: Readonly< + Record + >; + readonly effects: Readonly>; + readonly entities: Readonly>; + readonly senses: Readonly>; +} + +export interface CheckedWorldEntityDefinition { + readonly address: LocalResourceReference; + readonly alias: string; + readonly dynamics_address: string; +} + +export interface CheckedWorldSenseDefinition { + readonly address: LocalResourceReference; + readonly dynamics_senses: readonly string[]; + readonly output: "simfile.numeric-observation.v1"; +} + +export type CheckedWorldAffordanceTargetSelector = + | { readonly kind: "holder" } + | { + readonly kind: "fixed"; + readonly targets: readonly LocalResourceReference[]; + }; + +export interface CheckedWorldAffordanceDefinition { + readonly address: LocalResourceReference; + readonly dynamics_action: string; + readonly input_schema: BoundedObjectJsonSchema; + readonly rejection_codes: readonly string[]; + readonly target_selector: CheckedWorldAffordanceTargetSelector; +} + +export interface CheckedWorldEffectDefinition { + readonly address: LocalResourceReference; + readonly dynamics_event: string; + readonly payload_schema: BoundedObjectJsonSchema; +} + +export interface WorldProjectedEffect { + readonly effect: LocalResourceReference; + readonly payload: ReadonlyDynamicsJsonObject; +} + +export interface WorldSurfaceRegistry { + readonly affordances: readonly CheckedWorldAffordanceDefinition[]; + readonly api_version: typeof WORLD_SURFACE_API_VERSION; + readonly effects: readonly CheckedWorldEffectDefinition[]; + readonly entities: readonly CheckedWorldEntityDefinition[]; + readonly senses: readonly CheckedWorldSenseDefinition[]; + isAffordanceAvailable( + affordance: LocalResourceReference, + input: WorldAffordanceContext + ): boolean; + lowerAffordance( + affordance: LocalResourceReference, + input: WorldAffordanceLoweringInput + ): ReadonlyDynamicsJsonObject; + projectAffordanceResult( + affordance: LocalResourceReference, + result: WorldMechanicsResult + ): ReadonlyDynamicsJsonObject | undefined; + projectEffect(dynamicsEvent: string, payload: unknown): WorldProjectedEffect; + projectSense( + sense: LocalResourceReference, + input: WorldSenseProjectionInput + ): ReadonlyWorldSurfaceObservation; +} + +export interface DynamicsWorldModule { + createDynamicsProvider(): DynamicsProvider; + createWorldSurfaceDefinition(): WorldSurfaceDefinition; +} diff --git a/src/world/AGENTS.md b/src/world/AGENTS.md new file mode 100644 index 0000000..10e3e06 --- /dev/null +++ b/src/world/AGENTS.md @@ -0,0 +1,85 @@ +# World Address Modules + +This folder owns Simfile's public world-resource address boundary. + +- `addresses.ts` parses authored local references and resolves them into canonical + world addresses. Authored input stays local; only resolution emits `world://`. +- `addresses.test.ts` covers the public validation, path, collision, and isolation + contract. +- `grants.ts` resolves checked, declared world-surface grants and binds them to + injected, round-trippable issuer principals; it owns neither credentials nor + bearer authentication. +- `capabilityManifest.ts` emits and parses immutable, canonical capability + documents from B18 bound grants and callback-free checked surface metadata. + It never invokes world-surface callbacks or exposes action ingress. +- `index.ts` is the public barrel. Consumers must import this address API through + the barrel rather than deep-importing implementation files. + +- `ledger.ts` owns the bounded, per-principal B21 read audit and hostile request + parsing. It retains neither bearer tokens nor token digests. +- `runtime.ts` composes checked B18/B19/B20 dependencies into read operations. + `observe.ts` and `affordances.ts` own their callback-guarded, availability-only + read orchestration; neither owns action ingress or dynamics mutation. + +- `decisionRegistry.ts` owns decision-token secrets/digests, lifecycle admission, + first-act consumption, versioned snapshot/restore, and C/A/F closure. It does not own bearer + authentication, grants, request idempotency, wake cadence, or mechanics. +- `decisionRegistrySnapshot.ts` is the pure snapshot clone/freeze and hostile-input + parser boundary. It owns no live registry state, raw token, or secret key. +- `decisionRegistry.test.ts`, `decisionRegistryRestore.test.ts`, and + `decisionRegistryReachability.test.ts` cover live behavior, hostile restore, + and reconstructible clock/history invariants respectively. + +- `act.ts` owns hostile `world.act` ingress. It reserves a decision before any + callback, queues only host-built mechanics attempts, and commits only after a + preallocated action-journal cell is durable in memory. Its `denyWith` choke + point bounds every agent-visible rejection reason and optional schema path. +- `actDecisionToken.test.ts` proves decision-token refusal causes through the public `runtime.act` surface. +- `actionJournal.ts`, `actionResults.ts`, and `clockAuthority.ts` are host-only + B22 state. They are not part of the public world barrel; the clock directly + steps the same trusted `DynamicsSession` supplied by the Simfile host and + immediately joins returned mechanics truth. Retaining that session handle is + trusted-host authority, never agent authority. +- `actionJournalInspection.ts` binds issued runtimes to a host-only, read-only + journal snapshot capability. It is intentionally absent from every public + barrel and package export. +- `actionRefusalJournal.ts` is the host-only, drainable world-ingress refusal + stream. It retains only tick, composed principal, closed reason/path, and an + ordinal. Its fixed-capacity ring never hides overwrite: a reader behind an + overwritten or otherwise unretained ordinal receives an explicit loss error. + The journal initializes a real host tick at issuance and caches each later + valid sample; if tick sampling fails during refusal, it records the last + successfully sampled real tick rather than throwing or inventing a sentinel. + It writes no files, is not checkpoint state, and is intentionally absent from + public barrels. + +- `actEnvelope.ts` is the transport-neutral canonical + `simfile.world-act-request.v1` byte codec. It owns semantic action fields and + request identity only; authenticated authority is never envelope data. +- `requestLedger.ts` owns request-id claims, exact canonical-byte replay, and + prepare/commit/abort reservations. `requestLedgerSnapshot.ts` owns its + hostile-input-safe versioned snapshot clone/parser. Neither module integrates + `WorldRuntime.act` or exposes a mutable map, reservation, or host authority + through a public barrel. +- `requestLedgerInspection.ts` is the host-only read-only snapshot seam for the + private runtime request ledger. It is intentionally absent from every public + barrel; runtime restore orchestration belongs to B24. + +- `actionResult.ts` owns the frozen public terminal action-result value/parser. + `actionResultLedger.ts` owns its private, issued principal-scoped result store + and cursor paging; only result values and page types enter the public barrel. + Its host reservations bind each principal to its actor and capability scope. + Commit 2 snapshot/restore must retain the private issuer/key, bindings, + retained per-principal entries and page/eviction frontiers, plus bounded + admission uniqueness state, so pre-restore cursors remain valid; none become + public or agent authority. +- `runtime.ts` owns one private result ledger per issued runtime. It reserves + the compiled manifest principals before claiming injected authorities, + registers only the read-only handle through the host seam, and keeps result + admission and result-store authority off the stable six-operation surface. +- `runtime.ts` also routes every returned `world.act` ingress rejection through + the refusal journal's single sanitizing choke point, including reentry, + unknown-principal, and closed-ingress early returns. + +Do not put dynamics identifiers or mechanics mappings in this folder. Those are a +separate internal contract. diff --git a/src/world/CLAUDE.md b/src/world/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/src/world/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/src/world/act.test.ts b/src/world/act.test.ts new file mode 100644 index 0000000..e4e3a19 --- /dev/null +++ b/src/world/act.test.ts @@ -0,0 +1,66 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { WORLD_ACT_INGRESS_REJECTION_REASONS } from "../world-surface/index.js"; +import { encodeWorldActEnvelope } from "./actEnvelope.js"; +import { denyWith } from "./act.js"; +import { runtimeActEnvelope, runtimeFixture, runtimeFixtureWithHooks } from "./runtime.test-helper.js"; +import type { WorldActIngressRejectionReason } from "./actTypes.js"; + +const context = (token: string) => ({ principal: "principal-red", decisionToken: token }); +const request = { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } }; +const denied = (reason: WorldActIngressRejectionReason, fieldPath?: string) => Object.freeze({ + disposition: "rejected_at_ingress" as const, + code: "world_action_denied" as const, + reason, + ...(fieldPath === undefined ? {} : { field_path: fieldPath }), +}); + +test("queues once and exact retry returns the original receipt", () => { + const fixture = runtimeFixture(); + const bytes = runtimeActEnvelope("first-action", request); + const first = fixture.runtime.act(context(fixture.red.token), bytes); + assert.equal(first.disposition, "queued"); + assert.deepEqual(fixture.runtime!.act(context(fixture.red.token), bytes), first); +}); + +test("malformed and non-native bytes receive only generic ingress denial", () => { + const malformed: unknown[] = [ + new Uint8Array(), + new TextEncoder().encode("not-json\n"), + Uint8Array.from(runtimeActEnvelope("mutated", request), (byte, index) => index === 0 ? byte ^ 1 : byte), + new Proxy(runtimeActEnvelope("proxy", request), {}), + { 0: 1, length: 1 }, + ]; + class ByteSubclass extends Uint8Array {} + malformed.push(new ByteSubclass(runtimeActEnvelope("subclass", request))); + for (const bytes of malformed) { + const fixture = runtimeFixture(); + assert.deepEqual(fixture.runtime!.act(context(fixture.red.token), bytes as never), denied("request_malformed")); + } +}); + +test("lowering failure aborts the claim so an identical request id can retry", () => { + let hostile = true; + const fixture = runtimeFixtureWithHooks({ lower: () => { + if (hostile) throw new Error("lower"); + return { force: 1 }; + } }); + const bytes = runtimeActEnvelope("retry-after-lowering", request); + assert.deepEqual(fixture.runtime!.act(context(fixture.red.token), bytes), denied("world_surface_failed")); + hostile = false; + assert.equal(fixture.runtime!.act(context(fixture.red.token), bytes).disposition, "queued"); +}); + +test("encoder owns hostile semantic values while runtime accepts only bytes", () => { + const getter = { request_id: "hostile", affordance: request.affordance, target: request.target } as Record; + Object.defineProperty(getter, "input", { enumerable: true, get: () => { throw new Error("request getter"); } }); + assert.throws(() => encodeWorldActEnvelope(getter), TypeError); +}); + +test("rejection vocabulary and denyWith remain closed and bounded", () => { + assert.equal(Object.isFrozen(WORLD_ACT_INGRESS_REJECTION_REASONS), true); + assert.equal(new Set(WORLD_ACT_INGRESS_REJECTION_REASONS).size, WORLD_ACT_INGRESS_REJECTION_REASONS.length); + assert.deepEqual(denyWith("not-in-vocabulary"), denied("internal_error")); + assert.deepEqual(denyWith("request_malformed", "safe."), denied("internal_error")); + assert.deepEqual(denyWith("request_malformed", `safe.${"x".repeat(256)}`), denied("internal_error")); +}); diff --git a/src/world/act.ts b/src/world/act.ts new file mode 100644 index 0000000..0ca71e5 --- /dev/null +++ b/src/world/act.ts @@ -0,0 +1,243 @@ +import type { DynamicsSession } from "../dynamics/session.js"; +import { isDynamicsRetainedActionCapacityError } from "../dynamics/retainedCapacity.js"; +import { sameDynamicsSessionSnapshot } from "../dynamics/sameDynamicsSessionSnapshot.js"; +import type { ReadonlyDynamicsJsonObject } from "../dynamics/types.js"; +import { + isWorldActIngressRejectionFieldPath, + isWorldActIngressRejectionReason, + readWorldSurfaceRejection, + type WorldActIngressRejectionReason, + type WorldSurfaceRegistry, +} from "../world-surface/index.js"; +import { parseWorldSurfaceObservation } from "../world-surface/observation.js"; +import { resolveWorldAddress, type CanonicalWorldAddress, type LocalResourceReference } from "./addresses.js"; +import type { ActionAuditReservation, ActionJournalReservation, WorldActionJournal } from "./actionJournal.js"; +import type { WorldActIngressReceipt, WorldActIngressRejection, WorldActQueuedReceipt } from "./actTypes.js"; +import type { ParsedWorldActEnvelope } from "./actEnvelope.js"; +import type { CapabilityManifest } from "./capabilityManifest.js"; +import { + readDecisionRegistryErrorCode, + reserveDecisionForAct, + type DecisionActReservation, + type DecisionRegistry, +} from "./decisionRegistry.js"; +import { isCanonicalDecisionToken } from "./decisionRegistryInput.js"; +import { observeScopedWorldRuntime } from "./observe.js"; +import type { WorldRuntimeIdentity } from "./runtime.js"; +import type { WorldRequestLedger, WorldRequestReservation } from "./requestLedger.js"; + +type Dependencies = Readonly<{ + dynamics: DynamicsSession; + surfaceRegistry: WorldSurfaceRegistry; + decisionRegistry: DecisionRegistry; + journal: WorldActionJournal; + requestLedger: WorldRequestLedger; + runId: string; + worldId: string; + worldInstanceId: string; + closeMechanics: () => void; + refuse: ( + reason: WorldActIngressRejectionReason, + fieldPath?: string, + ) => WorldActIngressRejection; +}>; +type Checked = Readonly<{ affordance: CanonicalWorldAddress; target: CanonicalWorldAddress; input: unknown }>; +const local = (worldId: string, address: LocalResourceReference): CanonicalWorldAddress => resolveWorldAddress({ id: worldId as never }, address); +const unmutated = (dynamics: DynamicsSession, snapshot: ReturnType, tick: number): boolean => { + try { if (dynamics.nextTick === tick && sameDynamicsSessionSnapshot(dynamics.snapshot(), snapshot)) return true; } catch { /* restore below */ } + try { dynamics.restore(snapshot); } catch { /* caller denies */ } + return false; +}; + +/** @internal the only constructor for agent-visible ingress rejections. */ +export const denyWith = (reason: unknown, fieldPath?: unknown): WorldActIngressRejection => { + if (!isWorldActIngressRejectionReason(reason) + || (fieldPath !== undefined && !isWorldActIngressRejectionFieldPath(fieldPath))) { + return Object.freeze({ + disposition: "rejected_at_ingress", + code: "world_action_denied", + reason: "internal_error", + }); + } + return Object.freeze({ + disposition: "rejected_at_ingress", + code: "world_action_denied", + reason, + ...(fieldPath === undefined ? {} : { field_path: fieldPath }), + }); +}; + +const abortReservations = ( + dependencies: Dependencies, + request: WorldRequestReservation | undefined, + decision: DecisionActReservation | undefined, + cell: ActionJournalReservation | undefined, + pre: ReturnType | undefined, +): void => { + try { request?.abort(); } catch { /* already settled or capacity-closed */ } + try { decision?.abort(); } catch { /* already settled */ } + try { cell?.abort(); } catch { /* already settled */ } + if (pre !== undefined) try { dependencies.dynamics.restore(pre); } catch { /* caller denies */ } +}; + +/** @internal synchronous B23 ingress; runtime owns the gate and context parsing. */ +export const actWorldRuntime = ( + dependencies: Dependencies, + manifest: CapabilityManifest, + principal: string, + token: string, + envelopeBytes: Uint8Array, + stickyReentry: () => boolean, +): WorldActIngressReceipt => { + let audit: ActionAuditReservation | undefined; + let decision: DecisionActReservation | undefined; + let cell: ActionJournalReservation | undefined; + let request: WorldRequestReservation | undefined; + let pre: ReturnType | undefined; + let queuedAuditCommitted = false; + let terminalAuditCapacity = false; + let surfaceFailure: { readonly error: unknown } | undefined; + const callSurface = (call: () => Result): Result => { + try { return call(); } catch (error) { + surfaceFailure = { error }; + throw error; + } + }; + const deny = ( + reason: WorldActIngressRejectionReason, + fieldPath?: string, + close = false, + ): WorldActIngressReceipt => { + const terminal = close || terminalAuditCapacity || audit?.terminal_capacity === true || dependencies.requestLedger.closed; + abortReservations(dependencies, request, decision, cell, pre); + try { audit?.commit("denied"); } catch { close = true; } + audit = undefined; + if (terminal || close) { + try { dependencies.requestLedger.close(); } catch { /* denial remains authoritative */ } + try { dependencies.closeMechanics(); } catch { /* denial remains authoritative */ } + } + return dependencies.refuse(reason, fieldPath); + }; + try { + const authority = Object.freeze({ + principal, + run_id: dependencies.runId, + world_id: dependencies.worldId, + world_instance_id: dependencies.worldInstanceId, + }); + const claim = dependencies.requestLedger.beginClaim({ bytes: envelopeBytes, authority }); + if (claim.kind === "replay") return claim.receipt; + if (claim.kind === "malformed") { + try { audit = dependencies.journal.reserveAudit(principal); } catch { return deny("capacity_exhausted", undefined, true); } + terminalAuditCapacity = audit.terminal_capacity; + return deny(terminalAuditCapacity ? "capacity_exhausted" : "request_malformed"); + } + if (claim.kind === "conflict") { + try { audit = dependencies.journal.reserveAudit(principal); } catch { return deny("capacity_exhausted", undefined, true); } + terminalAuditCapacity = audit.terminal_capacity; + return deny(terminalAuditCapacity ? "capacity_exhausted" : "request_conflict"); + } + const envelope: ParsedWorldActEnvelope = claim.envelope; + request = claim.reservation; + try { audit = dependencies.journal.reserveAudit(principal); } catch { return deny("capacity_exhausted", undefined, true); } + terminalAuditCapacity = audit.terminal_capacity; + if (terminalAuditCapacity) return deny("capacity_exhausted"); + const atTick = dependencies.dynamics.nextTick; + if (!Number.isSafeInteger(atTick)) return deny("world_state_unstable"); + if (stickyReentry()) return deny("ingress_reentered"); + if (!isCanonicalDecisionToken(token)) return deny("decision_token_invalid"); + try { + decision = reserveDecisionForAct(dependencies.decisionRegistry, { + principal, runId: dependencies.runId, worldInstanceId: dependencies.worldInstanceId, token, atTick, + }); + } catch (error) { + const code = readDecisionRegistryErrorCode(error); + if (code === "token_consumed") return deny("decision_token_consumed"); + if (code === "token_expired") return deny("decision_token_expired"); + if (code === "token_invalid") return deny("decision_token_invalid"); + throw error; + } + const requestValue: Checked = Object.freeze({ + affordance: envelope.affordance as CanonicalWorldAddress, + target: envelope.target as CanonicalWorldAddress, + input: envelope.input, + }); + const granted = manifest.affordances.find((entry) => entry.address === requestValue.affordance); + if (granted === undefined) return deny("affordance_not_granted"); + const targetAllowed = granted.target_selector.kind === "holder" + ? requestValue.target === manifest.holder.entity + : granted.target_selector.targets.includes(requestValue.target); + if (!targetAllowed) return deny("target_not_granted"); + const affordance = dependencies.surfaceRegistry.affordances.find((entry) => local(manifest.world.id, entry.address) === requestValue.affordance); + const holder = dependencies.surfaceRegistry.entities.find((entry) => local(manifest.world.id, entry.address) === manifest.holder.entity); + const target = dependencies.surfaceRegistry.entities.find((entry) => local(manifest.world.id, entry.address) === requestValue.target); + if (affordance === undefined || holder === undefined || target === undefined) return deny("resource_undeclared"); + pre = dependencies.dynamics.snapshot(); + const identity: WorldRuntimeIdentity = Object.freeze({ + run_id: manifest.run_id, world_id: manifest.world.id, world_instance_id: manifest.world.instance_id, + manifest_digest: manifest.manifest_digest, state_version: atTick, + }); + const observation = callSurface(() => { + const channels = manifest.senses.map((sense) => observeScopedWorldRuntime(dependencies, manifest, identity, sense.address).observation.channels).flat(); + return parseWorldSurfaceObservation({ channels }, "aggregate action observation"); + }); + if (!unmutated(dependencies.dynamics, pre, atTick)) return deny("world_state_unstable"); + if (stickyReentry()) return deny("ingress_reentered"); + const available = callSurface(() => dependencies.surfaceRegistry.isAffordanceAvailable( + affordance.address, + Object.freeze({ holder: holder.address, observation, target: target.address }), + )); + if (available !== true) return deny("affordance_unavailable"); + if (!unmutated(dependencies.dynamics, pre, atTick)) return deny("world_state_unstable"); + if (stickyReentry()) return deny("ingress_reentered"); + const lowered = callSurface(() => dependencies.surfaceRegistry.lowerAffordance(affordance.address, Object.freeze({ + holder: holder.address, target: target.address, observation, input: requestValue.input as ReadonlyDynamicsJsonObject, + }))); + if (!unmutated(dependencies.dynamics, pre, atTick)) return deny("world_state_unstable"); + if (stickyReentry()) return deny("ingress_reentered"); + const expected = pre.next_action_sequence; + if (!Number.isSafeInteger(expected) || expected < 1) return deny("internal_error"); + const receipt: WorldActQueuedReceipt = Object.freeze({ + disposition: "queued", receipt_id: `world-act-${expected}`, decision_id: decision.decisionId, identity, apply_tick: atTick, + }); + cell = dependencies.journal.reserve(receipt, expected); + const queue = dependencies.dynamics.queueAction({ + act_id: receipt.receipt_id, action: affordance.dynamics_action, actor: holder.dynamics_address, + at_tick: atTick, input: lowered, origin: "agentic", principal_id: principal, target: target.dynamics_address, + }); + if (!queue.queued) return deny("internal_error"); + if (queue.act_id !== receipt.receipt_id) return deny("internal_error"); + if (queue.apply_tick !== atTick) return deny("internal_error"); + if (queue.sequence !== expected) return deny("internal_error"); + if (stickyReentry()) return deny("ingress_reentered"); + const queued = Object.freeze({ + receipt_id: receipt.receipt_id, decision_id: receipt.decision_id, principal, + holder: manifest.holder.entity, affordance: requestValue.affordance, target: requestValue.target, + at_tick: atTick, dynamics_sequence: expected, mechanics_action: affordance.dynamics_action, + mechanics_actor: holder.dynamics_address, mechanics_target: target.dynamics_address, + lowered_input: lowered, identity, + }); + cell.persist(queued); + cell.prepareAuthorization(); + request.prepare({ at_tick: atTick, queued_action: queued, receipt }); + audit.commit("queued"); + audit = undefined; + queuedAuditCommitted = true; + request.commit(); request = undefined; + decision.commit(); decision = undefined; + cell.authorize(); cell = undefined; + return receipt; + } catch (error) { + if (audit === undefined && !queuedAuditCommitted) { + try { audit = dependencies.journal.reserveAudit(principal); } catch { /* the journal may have closed with the failure */ } + } + const bounded = readWorldSurfaceRejection(error); + const capacity = terminalAuditCapacity || dependencies.requestLedger.closed + || isDynamicsRetainedActionCapacityError(error); + // Only calls made through callSurface are known to originate in surface + // orchestration; other unstructured throws are host-internal failures. + const reason = capacity ? "capacity_exhausted" + : bounded?.reason ?? (surfaceFailure?.error === error ? "world_surface_failed" : "internal_error"); + return deny(reason, bounded?.fieldPath, capacity); + } +}; diff --git a/src/world/actDecisionToken.test.ts b/src/world/actDecisionToken.test.ts new file mode 100644 index 0000000..63a2139 --- /dev/null +++ b/src/world/actDecisionToken.test.ts @@ -0,0 +1,76 @@ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import test from "node:test"; + +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import { + runtimeActEnvelope, + runtimeFixtureWithHooks, +} from "./runtime.test-helper.js"; +import type { WorldActIngressRejectionReason } from "./actTypes.js"; + +const request = { + affordance: "world://pitch/affordance/kick", + target: "world://pitch/entity/ball", + input: { force: 1 }, +}; +const denied = (reason: WorldActIngressRejectionReason) => Object.freeze({ + disposition: "rejected_at_ingress" as const, + code: "world_action_denied" as const, + reason, +}); +const act = ( + fixture: ReturnType, + requestId: string, + decisionToken: string, +) => fixture.runtime!.act( + { principal: "principal-red", decisionToken }, + runtimeActEnvelope(requestId, request), +); + +test("a second public act with a consumed token names the consumed cause", () => { + const fixture = runtimeFixtureWithHooks({}); + assert.equal(act(fixture, "consume-first", fixture.red.token).disposition, "queued"); + assert.deepEqual( + act(fixture, "consume-second", fixture.red.token), + denied("decision_token_consumed"), + ); +}); + +test("public acts distinguish every invalid decision-token form", () => { + const unminted = Buffer.from(new Uint8Array(32).fill(99)).toString("base64url"); + const cases = [ + ["missing", ""], + ["garbled", "not-a-canonical-token"], + ["unminted", unminted], + ] as const; + for (const [name, token] of cases) { + const fixture = runtimeFixtureWithHooks({}); + assert.deepEqual( + act(fixture, `invalid-${name}`, token), + denied("decision_token_invalid"), + name, + ); + } + + const foreign = runtimeFixtureWithHooks({}); + assert.deepEqual( + act(foreign, "invalid-other-principal", foreign.blue.token), + denied("decision_token_invalid"), + ); +}); + +test("a public act after the decision window names the expired cause", () => { + const fixture = runtimeFixtureWithHooks( + {}, + true, + { runId: "run-1", worldInstanceId: "instance-1", decisionValidThroughTick: 0 }, + ); + const clock = readWorldRuntimeClockAuthority(fixture.runtime); + assert.ok(clock); + clock.stepDynamics(); + assert.deepEqual( + act(fixture, "expired-after-step", fixture.red.token), + denied("decision_token_expired"), + ); +}); diff --git a/src/world/actEnvelope.test.ts b/src/world/actEnvelope.test.ts new file mode 100644 index 0000000..d0c1ecf --- /dev/null +++ b/src/world/actEnvelope.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import test from "node:test"; +import { encodeWorldActEnvelope, parseWorldActEnvelope, WORLD_ACT_ENVELOPE_VERSION } from "./actEnvelope.js"; + +const request = (input: unknown = { direction: 1, intensity: 0.5 }) => ({ + request_id: "request-1", affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/red", input, +}); +const bytesOf = (text: string): Uint8Array => new TextEncoder().encode(text); +const textOf = (bytes: Uint8Array | readonly number[]): string => new TextDecoder().decode(bytes instanceof Uint8Array ? bytes : Uint8Array.from(bytes)); +const rejects = (input: unknown): void => assert.throws(() => parseWorldActEnvelope(input), TypeError); + +test("canonical action envelopes round-trip with byte stability", () => { + const encoded = encodeWorldActEnvelope(request({ z: 1, a: "é" })); + const parsed = parseWorldActEnvelope(encoded); + assert.equal(parsed.version, WORLD_ACT_ENVELOPE_VERSION); + assert.equal(parsed.request_id, "request-1"); + assert.deepEqual({ ...(parsed.input as Record) }, { a: "é", z: 1 }); + assert.equal(textOf(encoded), textOf(parsed.bytes)); + assert.deepEqual(parseWorldActEnvelope(Buffer.from(encoded)), parsed); + assert.equal(Object.isFrozen(parsed.bytes), true); + assert.throws(() => (parsed.bytes as number[])[0] = 0, TypeError); + encoded[0] ^= 1; + assert.notEqual(encoded[0], parsed.bytes[0]); +}); + +test("the encoder copies hostile JSON and admits only the four semantic input fields", () => { + const input = { nested: { value: 1 } }; + const encoded = encodeWorldActEnvelope({ ...request(input) }); + input.nested.value = 9; + assert.match(textOf(encoded), /"value":1/u); + for (const hostile of [ + { ...request(), principal: "p" }, + { ...request(), input: { __proto__: { polluted: true } } }, + { ...request(), input: new Date() }, + { ...request(), input: { get value() { throw new Error("accessor"); } } }, + { ...request(), input: new Uint8Array([1]) }, + ]) assert.throws(() => encodeWorldActEnvelope(hostile), TypeError); + const proxy = new Proxy(request(), {}); + assert.throws(() => encodeWorldActEnvelope(proxy), TypeError); + const aliased: Record = { value: 1 }; + assert.throws(() => encodeWorldActEnvelope({ ...request(), input: [aliased, aliased] }), TypeError); +}); + +test("parser rejects noncanonical framing, schema, duplicate keys, and UTF-8", () => { + const canonical = textOf(encodeWorldActEnvelope(request())); + for (const text of [ + ` ${canonical}`, + canonical.replace("\"affordance\"", "\"target\""), + canonical.replace(`"${WORLD_ACT_ENVELOPE_VERSION}"`, `"other.v1"`), + canonical.replace(/\}\n$/u, ",\"extra\":true}\n"), + canonical.replace(/,"target"/u, ""), + canonical.replace(/"request_id":"request-1"/u, `"request_id":"request-1","request_id":"other"`), + ]) rejects(bytesOf(text)); + rejects(bytesOf(`{"version":"${WORLD_ACT_ENVELOPE_VERSION}","request_id":"request-1","affordance":"a","target":"b","input":1}\n\n`)); + rejects(Uint8Array.from([0xc3, 0x28])); + class ByteSubclass extends Uint8Array {} + rejects(new ByteSubclass(encodeWorldActEnvelope(request()))); + rejects(new Proxy(encodeWorldActEnvelope(request()), {})); +}); + +test("request ids and addresses are bounded nonblank strings", () => { + for (const bad of [ + { ...request(), request_id: "" }, { ...request(), request_id: " " }, + { ...request(), request_id: "x".repeat(257) }, { ...request(), affordance: 1 }, + { ...request(), target: "" }, { ...request(), target: "x".repeat(257) }, + ]) assert.throws(() => encodeWorldActEnvelope(bad), TypeError); +}); diff --git a/src/world/actEnvelope.ts b/src/world/actEnvelope.ts new file mode 100644 index 0000000..8faf86c --- /dev/null +++ b/src/world/actEnvelope.ts @@ -0,0 +1,102 @@ +import { canonicalDynamicsJson } from "../dynamics/canonicalJson.js"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { copyHostileJson, type HostileJson } from "./hostileJson.js"; +import { copySafeUint8Array } from "./decisionRegistrySnapshot.js"; + +export const WORLD_ACT_ENVELOPE_VERSION = "simfile.world-act-request.v1" as const; + +export interface WorldActEnvelopeInput { + readonly version?: typeof WORLD_ACT_ENVELOPE_VERSION; + readonly request_id: string; + readonly affordance: string; + readonly target: string; + readonly input: unknown; +} + +export interface ParsedWorldActEnvelope { + readonly version: typeof WORLD_ACT_ENVELOPE_VERSION; + readonly request_id: string; + readonly affordance: string; + readonly target: string; + readonly input: unknown; + /** A frozen copy of the complete canonical wire identity. */ + readonly bytes: readonly number[]; +} + +const UTF8 = new TextEncoder(); +const binding = (value: unknown): value is string => typeof value === "string" + && value.length > 0 && value.length <= DYNAMICS_LIMITS.identifier_code_units && value === value.trim(); +const fields = ["version", "request_id", "affordance", "target", "input"] as const; +const inputFields = ["request_id", "affordance", "target", "input"] as const; +const fail = (message = "invalid world action envelope"): never => { throw new TypeError(message); }; +const equalBytes = (left: Uint8Array | readonly number[], right: Uint8Array | readonly number[]): boolean => + left.length === right.length && left.every((byte, index) => byte === right[index]); + +const object = (value: HostileJson, expected: readonly string[] = fields): Readonly> | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const keys = Object.keys(value); + return keys.length === expected.length && keys.every((key) => (expected as readonly string[]).includes(key)) + && expected.every((key) => Object.hasOwn(value, key)) ? value as Readonly> : undefined; +}; + +const checkEnvelope = (value: unknown): ParsedWorldActEnvelope => { + const copy = copyHostileJson(value); + const source = object(copy); + if (source === undefined) throw new TypeError("invalid envelope object"); + const requestId = source.request_id; const affordance = source.affordance; const target = source.target; + if (source.version !== WORLD_ACT_ENVELOPE_VERSION || typeof requestId !== "string" || typeof affordance !== "string" + || typeof target !== "string" || !binding(requestId) || !binding(affordance) || !binding(target)) fail(); + const safeRequestId = requestId as string; + const safeAffordance = affordance as string; + const safeTarget = target as string; + return Object.freeze({ + version: WORLD_ACT_ENVELOPE_VERSION, + request_id: safeRequestId, + affordance: safeAffordance, + target: safeTarget, + input: source.input, + bytes: Object.freeze([] as number[]), + }); +}; + +const wire = (value: WorldActEnvelopeInput): Uint8Array => { + const copy = copyHostileJson(value); + const source = object(copy, Object.hasOwn(copy as object, "version") ? fields : inputFields); + if (source === undefined) throw new TypeError("invalid envelope object"); + const requestId = source.request_id; const affordance = source.affordance; const target = source.target; + if ((source.version !== undefined && source.version !== WORLD_ACT_ENVELOPE_VERSION) + || !binding(requestId) || !binding(affordance) || !binding(target)) fail(); + const canonical = canonicalDynamicsJson({ + version: WORLD_ACT_ENVELOPE_VERSION, + request_id: requestId, + affordance, + target, + input: source.input, + }); + return UTF8.encode(`${canonical}\n`); +}; + +export const encodeWorldActEnvelope = (input: unknown): Uint8Array => { + try { return wire(input as WorldActEnvelopeInput); } catch { return fail(); } +}; + +export const parseWorldActEnvelope = (input: unknown): ParsedWorldActEnvelope => { + try { + const raw = copySafeUint8Array(input); + if (raw === undefined) throw new TypeError("invalid envelope bytes"); + if (raw.byteLength === 0 || raw.byteLength > DYNAMICS_LIMITS.retained_action_code_units) fail(); + const text = new TextDecoder("utf-8", { fatal: true }).decode(raw); + if (!text.endsWith("\n") || text.slice(0, -1).includes("\n")) fail("invalid envelope framing"); + const payload = text.slice(0, -1); + let decoded: unknown; + try { decoded = JSON.parse(payload); } catch { fail("invalid envelope JSON"); } + const checked = checkEnvelope(decoded); + const canonical = wire({ request_id: checked.request_id, affordance: checked.affordance, target: checked.target, input: checked.input }); + if (!equalBytes(raw, canonical)) fail("envelope is not canonical"); + return Object.freeze({ ...checked, bytes: Object.freeze(Array.from(raw)) }); + } catch { return fail(); } +}; + +export const tryParseWorldActEnvelope = (input: unknown): ParsedWorldActEnvelope | undefined => { + try { return parseWorldActEnvelope(input); } catch { return undefined; } +}; diff --git a/src/world/actIdempotency.test.ts b/src/world/actIdempotency.test.ts new file mode 100644 index 0000000..002fe5d --- /dev/null +++ b/src/world/actIdempotency.test.ts @@ -0,0 +1,396 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { actWorldRuntime, denyWith } from "./act.js"; +import { createWorldActionJournal, type WorldActionJournal } from "./actionJournal.js"; +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { createWorldRequestLedger } from "./requestLedger.js"; +import { runtimeActionJournalSnapshot, runtimeActionJournalStatus, runtimeActEnvelope, runtimeFixtureWithHooks, runtimeRequestLedgerSnapshot } from "./runtime.test-helper.js"; +import type { WorldActIngressRejectionReason } from "./actTypes.js"; +const request = Object.freeze({ + affordance: "world://pitch/affordance/kick", + target: "world://pitch/entity/ball", + input: { force: 1 }, +}); +const context = (principal: string, token: string) => ({ principal, decisionToken: token }); +const denied = (reason: WorldActIngressRejectionReason, fieldPath?: string) => Object.freeze({ + disposition: "rejected_at_ingress" as const, code: "world_action_denied" as const, reason, + ...(fieldPath === undefined ? {} : { field_path: fieldPath }), +}); +test("resolved actions replay one frozen receipt without repeating runtime effects", () => { + let observations = 0; + const fixture = runtimeFixtureWithHooks({ + observe: (input) => { + observations += 1; + return { channels: (input as { sense_addresses: readonly string[] }).sense_addresses.map((sense_address) => ({ + components: { x: 1 }, sense_address, subject_address: "object:red", + })) }; + }, + step: (input) => { + const action = (input as { actions: readonly { sequence: number }[] }).actions[0]!; + return { tick: 0, events: [], action_results: [{ accepted: true, sequence: action.sequence }] }; + }, + }); + const bytes = runtimeActEnvelope("idempotent-action", request); + const first = fixture.runtime!.act(context("principal-red", fixture.red.token), bytes); + assert.equal(first.disposition, "queued"); + const observationsAfterFirst = observations; + const admittedJournal = runtimeActionJournalSnapshot(fixture.runtime!)!; + assert.deepEqual(admittedJournal.audits, [{ principal: "principal-red", result: "queued" }]); + assert.equal(admittedJournal.cells.length, 1); + assert.equal(admittedJournal.cells[0]!.state, "authorized"); + assert.equal(runtimeRequestLedgerSnapshot(fixture.runtime!)!.record_count, 1); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 1); + assert.deepEqual(readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(), { tick: 0, action_results: 1, events: 0 }); + const retried = fixture.runtime!.act(context("principal-red", fixture.red.token), bytes); + assert.deepEqual(retried, first); + assert.equal(observations, observationsAfterFirst); + const journal = runtimeActionJournalSnapshot(fixture.runtime!)!; + assert.equal(journal.audits.length, 1); + assert.equal(journal.cells.length, 1); + assert.equal(journal.cells[0]!.state, "terminal"); + const ledger = runtimeRequestLedgerSnapshot(fixture.runtime!)!; + assert.equal(ledger.record_count, 1); + assert.equal(ledger.records[0]!.receipt.receipt_id, first.receipt_id); +}); +test("caller byte mutation cannot alter the retained request identity", () => { + const fixture = runtimeFixtureWithHooks({}); + const bytes = runtimeActEnvelope("byte-isolation", request); + const original = Uint8Array.from(bytes); + const first = fixture.runtime!.act(context("principal-red", fixture.red.token), bytes); + assert.equal(first.disposition, "queued"); + bytes[0] ^= 1; + assert.deepEqual(fixture.runtime!.act(context("principal-red", fixture.red.token), bytes), denied("request_malformed")); + assert.deepEqual(fixture.runtime!.act(context("principal-red", fixture.red.token), original), first); + assert.equal(runtimeRequestLedgerSnapshot(fixture.runtime!)!.record_count, 1); +}); +test("changed canonical content and principal conflict under one request id", () => { + const fixture = runtimeFixtureWithHooks({}); + const bytes = runtimeActEnvelope("identity-bound", request); + const first = fixture.runtime!.act(context("principal-red", fixture.red.token), bytes); + assert.equal(first.disposition, "queued"); + assert.deepEqual(fixture.runtime!.act(context("principal-red", fixture.red.token), runtimeActEnvelope("identity-bound", { ...request, input: { force: 2 } })), denied("request_conflict")); + assert.deepEqual(fixture.runtime!.act(context("principal-blue", fixture.blue.token), bytes), denied("request_conflict")); + const journal = runtimeActionJournalSnapshot(fixture.runtime!)!; + assert.deepEqual(journal.audits, [ + { principal: "principal-blue", result: "denied" }, + { principal: "principal-red", result: "denied" }, + { principal: "principal-red", result: "queued" }, + ]); + assert.equal(runtimeRequestLedgerSnapshot(fixture.runtime!)!.record_count, 1); + assert.equal(journal.cells.length, 1); +}); +test("queued audit failure rolls back every prepared authority after the real queue", () => { + let observed = 0; + let available = 0; + let lowered = 0; + const fixture = runtimeFixtureWithHooks({ + observe: (input) => { observed += 1; return { channels: (input as { sense_addresses: readonly string[] }).sense_addresses.map((sense_address) => ({ components: { x: 1 }, sense_address, subject_address: "object:red" })) }; }, + available: () => { available += 1; return true; }, + lower: () => { lowered += 1; return { force: 1 }; }, + }, false); + const journal = createWorldActionJournal(); journal.reservePrincipals(["principal-red"]); + let queuedFailure = true; let deniedAudits = 0; let cellPrepared = false; let queueReached = false; + const failingJournal: WorldActionJournal = { + reservePrincipals: (principals) => journal.reservePrincipals(principals), + reserveAudit: (principal) => { + const reservation = journal.reserveAudit(principal); + return Object.freeze({ terminal_capacity: reservation.terminal_capacity, commit: (result: "queued" | "denied") => { + if (result === "queued" && queuedFailure) { + queuedFailure = false; queueReached = fixture.dynamics.snapshot().pending_actions.length === 1; + throw new Error("forced queued audit failure"); + } + reservation.commit(result); if (result === "denied") deniedAudits += 1; + } }); + }, + audit: (principal, result) => journal.audit(principal, result), + reserve: (receipt, sequence) => { + const reservation = journal.reserve(receipt, sequence); + return Object.freeze({ + persist: (record: Parameters[0]) => reservation.persist(record), + prepareAuthorization: () => { cellPrepared = true; reservation.prepareAuthorization(); }, + authorize: () => reservation.authorize(), abort: () => reservation.abort(), + }); + }, + pending: (tick) => journal.pending(tick), reserveTerminals: (tick) => journal.reserveTerminals(tick), + terminal: (record) => journal.terminal(record), project: (record) => journal.project(record), + close: () => journal.close(), snapshot: () => journal.snapshot(), restore: (input) => journal.restore(input), + }; + const ledger = createWorldRequestLedger({ max_records: 9_999 }); + let requestPrepared = false; + const realBegin = ledger.beginClaim.bind(ledger); + const requestLedger = { ...ledger, beginClaim: (input: unknown) => { + const claim = realBegin(input); + if (claim.kind !== "new") return claim; + return { ...claim, reservation: { ...claim.reservation, prepare: (preparation: Parameters[0]) => { requestPrepared = true; claim.reservation.prepare(preparation); } } }; + } }; + const beforeDynamics = fixture.dynamics.snapshot(); + const beforeDecision = fixture.decisionRegistry.inspect(); + const receipt = actWorldRuntime({ + dynamics: fixture.dynamics, surfaceRegistry: fixture.surfaceRegistry, decisionRegistry: fixture.decisionRegistry, + journal: failingJournal, requestLedger, runId: "run-1", worldId: "pitch", worldInstanceId: "instance-1", closeMechanics: () => {}, refuse: denyWith, + }, fixture.capabilityManifests.find((item) => item.manifest.holder.principal === "principal-red")!.manifest, + "principal-red", fixture.red.token, runtimeActEnvelope("forced-queued-audit", request), () => false); + assert.deepEqual(receipt, denied("internal_error")); + assert.equal(requestPrepared, true); assert.equal(cellPrepared, true); assert.equal(queueReached, true); + assert.equal(fixture.decisionRegistry.inspect().decisions.at(-1)?.status, "active"); + assert.deepEqual(fixture.dynamics.snapshot(), beforeDynamics); + assert.deepEqual(fixture.decisionRegistry.inspect(), beforeDecision); + assert.deepEqual(ledger.snapshot().record_count, 0); + assert.deepEqual(journal.snapshot().cells, []); + assert.equal(deniedAudits, 1); + assert.equal(observed, 2); + assert.equal(available, 1); + assert.equal(lowered, 1); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + assert.equal(journal.snapshot().audits.length, 1); + const retry = actWorldRuntime({ dynamics: fixture.dynamics, surfaceRegistry: fixture.surfaceRegistry, decisionRegistry: fixture.decisionRegistry, + journal: failingJournal, requestLedger, runId: "run-1", worldId: "pitch", worldInstanceId: "instance-1", closeMechanics: () => {}, refuse: denyWith }, + fixture.capabilityManifests.find((item) => item.manifest.holder.principal === "principal-red")!.manifest, + "principal-red", fixture.red.token, runtimeActEnvelope("forced-queued-audit", request), () => false); + assert.equal(retry.disposition, "queued"); +}); +test("accepted-only permanent frontier closes all authorities on the final denial", () => { + let observed = 0; let available = 0; let lowered = 0; let projected = 0; let queued = 0; + let tokenState = 1; + const randomBytes = (): Uint8Array => { + const bytes = new Uint8Array(32); + let state = tokenState++; + for (let index = 0; index < bytes.length; index += 1) { + state = Math.imul(state ^ (state >>> 16), 2_246_822_519); + state = Math.imul(state ^ (state >>> 13), 3_266_489_917); + bytes[index] = state & 0xff; + } + return bytes; + }; + const fixture = runtimeFixtureWithHooks({ + observe: (input) => { observed += 1; return { channels: (input as { sense_addresses: readonly string[] }).sense_addresses.map((sense_address) => ({ components: { x: 1 }, sense_address, subject_address: "object:red" })) }; }, + available: () => { available += 1; return true; }, lower: () => { lowered += 1; return { force: 1 }; }, + project: () => { projected += 1; return { channels: [{ components: { x: 1 }, sense_address: "sense:vision", subject_address: "entity:red" }] }; }, + projectDetail: () => { projected += 1; return { channels: [{ components: { x: 1 }, sense_address: "sense:red-detail", subject_address: "entity:red" }] }; }, + randomBytes, + step: (input) => { queued += (input as { actions: readonly unknown[] }).actions.length; return { tick: (input as { tick: number }).tick, events: [], action_results: (input as { actions: readonly { sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: true, sequence })) }; }, + }); + let accepted = 0; let token = fixture.red.token; + let firstBytes: Uint8Array | undefined; + let firstReceipt: ReturnType["act"]> | undefined; + let beforeFinal: { observed: number; available: number; lowered: number; projected: number; queued: number; dynamics: ReturnType; decision: ReturnType } | undefined; + let final: ReturnType["act"]> | undefined; + for (; accepted < DYNAMICS_LIMITS.retained_action_records; accepted += 1) { + const tick = fixture.dynamics.nextTick; + beforeFinal = { observed, available, lowered, projected, queued, dynamics: fixture.dynamics.snapshot(), decision: fixture.decisionRegistry.inspect() }; + const bytes = runtimeActEnvelope(`accepted-capacity-${accepted}`, request); + const result = fixture.runtime!.act(context("principal-red", token), bytes); + if (result.disposition === "rejected_at_ingress") { final = result; break; } + assert.equal(result.disposition, "queued"); + if (accepted === 0) { firstBytes = Uint8Array.from(bytes); firstReceipt = result; } + readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + token = fixture.decisionRegistry.mint({ principal: "principal-red", issuedTick: fixture.dynamics.nextTick, validThroughTick: 20_000 }).token; + } + assert.ok(final !== undefined, "retained-record denial was not discovered below the shared bound"); + assert.deepEqual(final, denied("capacity_exhausted")); assert.ok(accepted > 0); assert.ok(accepted < DYNAMICS_LIMITS.retained_action_records); + const before = beforeFinal!; + assert.equal(observed, before.observed + 2, "observe delta"); + assert.equal(available, before.available + 1, "available delta"); + assert.equal(lowered, before.lowered + 1, "lower delta"); + assert.equal(projected, before.projected + 2, "project delta"); + assert.equal(queued, before.queued, "step delta"); + assert.deepEqual(fixture.dynamics.snapshot(), before.dynamics); assert.deepEqual(fixture.decisionRegistry.inspect(), before.decision); + const ledger = runtimeRequestLedgerSnapshot(fixture.runtime!)!; + assert.equal(ledger.closed, true); assert.equal(ledger.record_count, accepted); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + assert.deepEqual(runtimeActionJournalStatus(fixture.runtime!), { closed: true, audit_count: accepted + 1, cell_count: accepted }); + // Every preceding audit is accepted; the single extra status count is the final denial. + assert.throws(() => readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(), /closed/u); + assert.ok(firstBytes !== undefined); assert.ok(firstReceipt !== undefined); assert.ok(Object.isFrozen(firstReceipt)); + const replayBefore = { journal: runtimeActionJournalStatus(fixture.runtime!), ledger: runtimeRequestLedgerSnapshot(fixture.runtime!), dynamics: fixture.dynamics.snapshot(), decision: fixture.decisionRegistry.inspect(), observed, available, lowered, projected, queued }; + assert.deepEqual(fixture.runtime!.act(context("principal-red", "unusable-token"), firstBytes), firstReceipt); + assert.deepEqual(runtimeActionJournalStatus(fixture.runtime!), replayBefore.journal); + assert.deepEqual(runtimeRequestLedgerSnapshot(fixture.runtime!), replayBefore.ledger); + assert.deepEqual(fixture.dynamics.snapshot(), replayBefore.dynamics); + assert.deepEqual(fixture.decisionRegistry.inspect(), replayBefore.decision); + assert.deepEqual({ observed, available, lowered, projected, queued }, { observed: replayBefore.observed, available: replayBefore.available, lowered: replayBefore.lowered, projected: replayBefore.projected, queued: replayBefore.queued }); +}); +test("final audit slot denies before consuming a real decision", () => { + let observed = 0; let available = 0; let lowered = 0; let projected = 0; let queued = 0; + const fixture = runtimeFixtureWithHooks({ + observe: (input) => { observed += 1; return { channels: (input as { sense_addresses: readonly string[] }).sense_addresses.map((sense_address) => ({ components: { x: 1 }, sense_address, subject_address: "object:red" })) }; }, + available: () => { available += 1; return true; }, lower: () => { lowered += 1; return { force: 1 }; }, + project: () => { projected += 1; return { channels: [] }; }, + step: (input) => { queued += (input as { actions: readonly unknown[] }).actions.length; return { tick: (input as { tick: number }).tick, events: [], action_results: [] }; }, + }); + for (let index = 0; index < 9_999; index += 1) { + assert.deepEqual(fixture.runtime!.act(context("principal-red", "unusable-token"), runtimeActEnvelope(`audit-fill-${index}`, request)), denied("decision_token_invalid")); + } + const before = { + dynamics: fixture.dynamics.snapshot(), status: runtimeActionJournalStatus(fixture.runtime!), + ledger: runtimeRequestLedgerSnapshot(fixture.runtime!), calls: fixture.dynamicsCalls(), + observed, available, lowered, projected, queued, + }; + const decisionBefore = fixture.decisionRegistry.inspect(); + assert.deepEqual(fixture.runtime!.act(context("principal-red", fixture.red.token), runtimeActEnvelope("audit-final", request)), denied("capacity_exhausted")); + assert.deepEqual(fixture.dynamics.snapshot(), before.dynamics); + assert.deepEqual(fixture.decisionRegistry.inspect(), decisionBefore); + assert.deepEqual(runtimeActionJournalStatus(fixture.runtime!), { closed: true, audit_count: 10_000, cell_count: 0 }); + assert.deepEqual(runtimeRequestLedgerSnapshot(fixture.runtime!), { version: "simfile.world-request-ledger.v1", closed: true, record_count: 0, code_units: 0, records: [] }); + assert.equal(fixture.dynamicsCalls(), before.calls); assert.equal(observed, before.observed); + assert.equal(available, before.available); assert.equal(lowered, before.lowered); + assert.equal(projected, before.projected); assert.equal(queued, before.queued); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + assert.throws(() => readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(), /closed/u); +}); +test("nested act denial settles the outer audit and closes every authority", () => { + let runtime: NonNullable["runtime"]>; + let nested: ReturnType["act"]> | undefined; + let callbacks = 0; + const fixture = runtimeFixtureWithHooks({ + observe: () => { + callbacks += 1; + if (nested === undefined) nested = runtime!.act(context("principal-red", fixture.red.token), runtimeActEnvelope("nested-reentry", request)); + return { channels: [] }; + }, + }); + runtime = fixture.runtime!; + for (let index = 0; index < 9_998; index += 1) { + assert.deepEqual(runtime.act(context("principal-red", "unusable-token"), runtimeActEnvelope(`nested-fill-${index}`, request)), denied("decision_token_invalid")); + } + const beforeDynamics = fixture.dynamics.snapshot(); + const beforeDecision = fixture.decisionRegistry.inspect(); + const outer = runtime.act(context("principal-red", fixture.red.token), runtimeActEnvelope("outer-reentry", request)); + assert.deepEqual(outer, denied("ingress_reentered")); assert.deepEqual(nested, denied("ingress_reentered")); + assert.equal(callbacks, 2); + assert.deepEqual(runtimeActionJournalStatus(runtime), { closed: true, audit_count: 10_000, cell_count: 0 }); + assert.deepEqual(runtimeRequestLedgerSnapshot(runtime), { version: "simfile.world-request-ledger.v1", closed: true, record_count: 0, code_units: 0, records: [] }); + assert.deepEqual(fixture.dynamics.snapshot(), beforeDynamics); + assert.deepEqual(fixture.decisionRegistry.inspect(), beforeDecision); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + assert.throws(() => readWorldRuntimeClockAuthority(runtime)!.stepDynamics(), /closed/u); +}); +test("terminal-slot conflict closes authorities while exact replay remains available", () => { + let observed = 0; let available = 0; let lowered = 0; let projected = 0; let queued = 0; + const fixture = runtimeFixtureWithHooks({ + observe: (input) => { observed += 1; return { channels: (input as { sense_addresses: readonly string[] }).sense_addresses.map((sense_address) => ({ components: { x: 1 }, sense_address, subject_address: "object:red" })) }; }, + available: () => { available += 1; return true; }, lower: () => { lowered += 1; return { force: 1 }; }, + project: () => { projected += 1; return { channels: [{ components: { x: 1 }, sense_address: "sense:vision", subject_address: "entity:red" }] }; }, + step: (input) => { queued += (input as { actions: readonly unknown[] }).actions.length; return { tick: (input as { tick: number }).tick, events: [], action_results: (input as { actions: readonly { sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: true, sequence })) }; }, + }); + const original = runtimeActEnvelope("last-slot-conflict", request); + const first = fixture.runtime!.act(context("principal-red", fixture.red.token), original); + assert.equal(first.disposition, "queued"); + readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + let deniedAudits = 0; + for (let index = 0; index < 9_998; index += 1) { + assert.deepEqual(fixture.runtime!.act(context("principal-red", fixture.red.token), runtimeActEnvelope(`terminal-fill-${index}`, request)), denied("decision_token_consumed")); + deniedAudits += 1; + } + const before = { dynamics: fixture.dynamics.snapshot(), status: runtimeActionJournalStatus(fixture.runtime!), ledger: runtimeRequestLedgerSnapshot(fixture.runtime!), decision: fixture.decisionRegistry.inspect(), calls: fixture.dynamicsCalls(), observed, available, lowered, projected, queued }; + assert.deepEqual(fixture.runtime!.act(context("principal-red", fixture.red.token), runtimeActEnvelope("last-slot-conflict", { ...request, input: { force: 2 } })), denied("capacity_exhausted")); + const ledger = runtimeRequestLedgerSnapshot(fixture.runtime!)!; + assert.deepEqual(fixture.dynamics.snapshot(), before.dynamics); assert.deepEqual(fixture.decisionRegistry.inspect(), before.decision); + assert.equal(fixture.dynamicsCalls(), before.calls); assert.equal(ledger.closed, true); + assert.deepEqual({ observed, available, lowered, projected, queued }, { observed: before.observed, available: before.available, lowered: before.lowered, projected: before.projected, queued: before.queued }); + assert.equal(deniedAudits, 9_998); assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + assert.deepEqual(runtimeActionJournalStatus(fixture.runtime!), { closed: true, audit_count: 10_000, cell_count: 1 }); + assert.throws(() => readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(), /closed/u); + const replayBefore = { dynamics: fixture.dynamics.snapshot(), status: runtimeActionJournalStatus(fixture.runtime!), ledger: runtimeRequestLedgerSnapshot(fixture.runtime!), decision: fixture.decisionRegistry.inspect(), calls: fixture.dynamicsCalls(), observed, available, lowered, projected, queued }; + const replay = fixture.runtime!.act(context("principal-red", "wrong-token"), original); + assert.deepEqual(replay, first); assert.ok(Object.isFrozen(replay)); + assert.deepEqual(fixture.dynamics.snapshot(), replayBefore.dynamics); + assert.deepEqual(runtimeActionJournalStatus(fixture.runtime!), replayBefore.status); + assert.deepEqual(runtimeRequestLedgerSnapshot(fixture.runtime!), replayBefore.ledger); + assert.deepEqual(fixture.decisionRegistry.inspect(), replayBefore.decision); + assert.equal(fixture.dynamicsCalls(), replayBefore.calls); + assert.deepEqual({ observed, available, lowered, projected, queued }, { observed: replayBefore.observed, available: replayBefore.available, lowered: replayBefore.lowered, projected: replayBefore.projected, queued: replayBefore.queued }); +}); +test("queue failure aborts the claim and an identical request can retry", () => { + const fixture = runtimeFixtureWithHooks({ + step: (input) => ({ + tick: 0, + events: [], + action_results: (input as { actions: readonly { sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: true, sequence })), + }), + }); + for (let index = 0; index < 128; index += 1) { + fixture.dynamics.queueAction({ + act_id: `filler-${index}`, action: "wait", actor: "object:blue", at_tick: 0, + input: {}, origin: "agentic", principal_id: "principal-blue", target: "object:blue", + }); + } + const before = fixture.dynamics.snapshot(); + const bytes = runtimeActEnvelope("queue-retry", request); + assert.deepEqual(fixture.runtime!.act(context("principal-red", fixture.red.token), bytes), denied("internal_error")); + assert.deepEqual(fixture.dynamics.snapshot(), before); + assert.equal(runtimeRequestLedgerSnapshot(fixture.runtime!)!.record_count, 0); + fixture.dynamics.step(); + assert.equal(fixture.runtime!.act(context("principal-red", fixture.red.token), bytes).disposition, "queued"); +}); +test("a failed queue attempt never occupies the idempotency slot for its own retry", () => { + const fixture = runtimeFixtureWithHooks({ + step: (input) => ({ + tick: (input as { tick: number }).tick, + events: [], + action_results: (input as { actions: readonly { sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: true, sequence })), + }), + }); + const fill = () => { + const at_tick = fixture.dynamics.nextTick; + for (let index = 0; index < DYNAMICS_LIMITS.actions_per_tick; index += 1) { + fixture.dynamics.queueAction({ + act_id: `filler-${at_tick}-${index}`, action: "wait", actor: "object:blue", at_tick, + input: {}, origin: "agentic", principal_id: "principal-blue", target: "object:blue", + }); + } + }; + fill(); + const before = fixture.dynamics.snapshot(); + const bytes = runtimeActEnvelope("retry-after-queue-failure", request); + assert.deepEqual(fixture.runtime!.act(context("principal-red", fixture.red.token), bytes), denied("internal_error")); + // The failed attempt claimed nothing: no dynamics mutation, no ledger record, + // and ingress stays open, so the identical request is still retryable. + assert.deepEqual(fixture.dynamics.snapshot(), before); + assert.equal(runtimeRequestLedgerSnapshot(fixture.runtime!)!.record_count, 0); + assert.equal(runtimeActionJournalStatus(fixture.runtime!)!.closed, false); + // A second saturated tick pushes lifetime ingress past the per-tick retention + // bound: the retry is only admitted if no live ingress structure accumulates + // across ticks, i.e. the bound stays per-tick instead of capping the session. + fixture.dynamics.step(); + fill(); + fixture.dynamics.step(); + assert.ok(fixture.dynamics.snapshot().next_action_sequence > DYNAMICS_LIMITS.actions_per_tick * 2); + const retry = fixture.runtime!.act(context("principal-red", fixture.red.token), bytes); + assert.equal(retry.disposition, "queued"); + assert.equal(runtimeRequestLedgerSnapshot(fixture.runtime!)!.record_count, 1); + assert.equal(runtimeActionJournalStatus(fixture.runtime!)!.closed, false); +}); +test("exact retry after a tick advance and failed restore replays after mechanics closure", () => { + let callbacks = 0; + let failRestore = true; + const fixture = runtimeFixtureWithHooks({ + step: (input) => { + const action = (input as { actions: readonly { sequence: number }[] }).actions[0]!; + return { tick: 0, events: [], action_results: [{ accepted: true, sequence: action.sequence }] }; + }, + projectResult: () => { callbacks += 1; throw new Error("projection failure"); }, + failRestore: () => { + if (!failRestore) return false; + failRestore = false; + return true; + }, + }); + const bytes = runtimeActEnvelope("closed-replay", request); + const first = fixture.runtime!.act(context("principal-red", fixture.red.token), bytes); + assert.equal(first.disposition, "queued"); + const clock = readWorldRuntimeClockAuthority(fixture.runtime!)!; + assert.throws(() => clock.stepDynamics(), /restore failed/u); + const before = runtimeActionJournalSnapshot(fixture.runtime!)!; + const beforeDecision = fixture.decisionRegistry.inspect(); + const beforeTick = fixture.dynamics.nextTick; + assert.deepEqual(fixture.runtime!.act(context("principal-red", "wrong-token"), bytes), first); + assert.equal(callbacks, 1); + assert.deepEqual(runtimeActionJournalSnapshot(fixture.runtime!), before); + assert.deepEqual(fixture.decisionRegistry.inspect(), beforeDecision); + assert.equal(fixture.dynamics.nextTick, beforeTick); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + assert.deepEqual(fixture.runtime!.act(context("principal-red", fixture.red.token), runtimeActEnvelope("new-after-close", request)), denied("ingress_closed")); + assert.deepEqual(runtimeActionJournalSnapshot(fixture.runtime!), before); +}); diff --git a/src/world/actTypes.ts b/src/world/actTypes.ts new file mode 100644 index 0000000..05a0cec --- /dev/null +++ b/src/world/actTypes.ts @@ -0,0 +1,32 @@ +import type { ReadonlyDynamicsJsonObject } from "../dynamics/types.js"; +import type { WorldActIngressRejectionReason } from "../world-surface/index.js"; +import type { WorldRuntimeIdentity } from "./runtime.js"; +export type { WorldActIngressRejectionReason } from "../world-surface/index.js"; +export interface WorldActQueuedReceipt { + readonly disposition: "queued"; + readonly receipt_id: string; + readonly decision_id: string; + readonly identity: WorldRuntimeIdentity; + readonly apply_tick: number; +} +export interface WorldActIngressRejection { + readonly disposition: "rejected_at_ingress"; + readonly code: "world_action_denied"; + readonly reason: WorldActIngressRejectionReason; + readonly field_path?: string; +} +export type WorldActIngressReceipt = WorldActQueuedReceipt | WorldActIngressRejection; + +/** Host-only bridge from action ingress to the mechanics clock. */ +export interface QueuedWorldAction { + readonly receipt_id: string; readonly decision_id: string; readonly principal: string; + readonly holder: string; readonly affordance: string; readonly target: string; + readonly at_tick: number; readonly dynamics_sequence: number; readonly mechanics_action: string; + readonly mechanics_actor: string; readonly mechanics_target: string; + readonly lowered_input: ReadonlyDynamicsJsonObject; readonly identity: WorldRuntimeIdentity; +} +export type WorldActionTerminal = Readonly<{ + disposition: "applied" | "rejected_at_mechanics"; receipt_id: string; decision_id: string; + sequence: number; apply_tick: number; projection: "not_configured" | "projected" | "failed"; + public_code?: string; effect?: ReadonlyDynamicsJsonObject; +}>; diff --git a/src/world/actionJournal.test.ts b/src/world/actionJournal.test.ts new file mode 100644 index 0000000..31dca5e --- /dev/null +++ b/src/world/actionJournal.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWorldActionJournal, readWorldActionJournalStatus } from "./actionJournal.js"; + +const identity = Object.freeze({ run_id: "run", world_id: "world", world_instance_id: "instance", manifest_digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", state_version: 0 }); +const receipt = Object.freeze({ disposition: "queued" as const, receipt_id: "world-act-1", decision_id: "decision-000000000001", identity, apply_tick: 0 }); +const queued = Object.freeze({ receipt_id: "world-act-1", decision_id: "decision-000000000001", principal: "p", holder: "world://world/entity/p", affordance: "world://world/affordance/a", target: "world://world/entity/p", at_tick: 0, dynamics_sequence: 1, mechanics_action: "a", mechanics_actor: "object:p", mechanics_target: "object:p", lowered_input: Object.freeze({}), identity }); +const populated = () => { const journal = createWorldActionJournal(); journal.reservePrincipals(["p"]); const audit = journal.reserveAudit("p"); const cell = journal.reserve(receipt, 1); cell.persist(queued); audit.commit("queued"); cell.prepareAuthorization(); cell.authorize(); return journal; }; + +test("persists before the no-fail authorization flip and abort removes provisional state", () => { + const journal = createWorldActionJournal(); journal.reservePrincipals(["p"]); + const cell = journal.reserve(receipt, 1); cell.persist(queued); assert.equal(journal.pending(0).length, 0); + cell.abort(); assert.equal(journal.snapshot().cells.length, 0); + const committed = populated(); assert.equal(committed.pending(0).length, 1); +}); + +test("uses literal reservations and leaves no payload or map after abort", () => { + const journal = createWorldActionJournal(); journal.reservePrincipals(["p"]); + const audit = journal.reserveAudit("p"); + const cell = journal.reserve(receipt, 1); + assert.throws(() => cell.authorize()); + assert.throws(() => journal.snapshot()); + cell.persist(queued); assert.throws(() => journal.snapshot()); + cell.prepareAuthorization(); assert.throws(() => journal.snapshot()); audit.commit("queued"); + assert.doesNotThrow(() => cell.authorize()); + assert.throws(() => cell.authorize()); assert.throws(() => cell.abort()); + const provisional = journal.reserve(Object.freeze({ ...receipt, receipt_id: "world-act-2" }), 2); + provisional.persist(Object.freeze({ ...queued, receipt_id: "world-act-2", dynamics_sequence: 2 })); + provisional.abort(); + assert.equal(journal.snapshot().cells.length, 1); +}); + +test("round trips isolated journal state and terminal projection states", () => { + const source = populated(); source.terminal(Object.freeze({ disposition: "applied", receipt_id: "world-act-1", decision_id: "decision-000000000001", sequence: 1, apply_tick: 0, projection: "not_configured" })); + const snapshot = source.snapshot(); assert(Object.isFrozen(snapshot)); assert(Object.isFrozen(snapshot.cells)); assert(Object.isFrozen(snapshot.cells[0]!)); + const target = createWorldActionJournal(); target.restore(snapshot); assert.deepEqual(target.snapshot(), snapshot); + assert.throws(() => target.restore(snapshot)); + const altered = structuredClone(snapshot); (altered.cells[0]!.receipt.identity as { run_id: string }).run_id = "other"; + assert.equal(source.snapshot().cells[0]!.receipt.identity.run_id, "run"); +}); + +test("rejects hostile snapshots without getters and preserves a pristine journal", () => { + const snapshot = populated().snapshot(); const accessor = structuredClone(snapshot) as unknown as Record; + Object.defineProperty(accessor, "closed", { enumerable: true, get: () => { throw new Error("getter"); } }); + const symbol = structuredClone(snapshot) as unknown as Record; symbol[Symbol("x")] = true; + const alias = structuredClone(snapshot) as unknown as { cells: unknown[] }; alias.cells.push(alias.cells[0]!); + for (const candidate of [undefined, {}, accessor, symbol, alias, { ...snapshot, version: "other" }, { ...snapshot, lanes: [] }]) { + const journal = createWorldActionJournal(); assert.throws(() => journal.restore(candidate)); assert.deepEqual(journal.snapshot().cells, []); + } +}); + +test("rejects unsafe queued payloads before authorization and keeps unknown lanes healthy", () => { + const journal = createWorldActionJournal(); journal.reservePrincipals(["p"]); + assert.throws(() => journal.reserveAudit("unknown")); + const cell = journal.reserve(receipt, 1); let accessed = false; + const getter = Object.create(null) as Record; + Object.defineProperty(getter, "x", { enumerable: true, get: () => { accessed = true; return 1; } }); + const proxy = new Proxy({}, { get: () => { accessed = true; return 1; } }); + for (const lowered_input of [getter, proxy, { then: 1 }, [,,]]) { + assert.throws(() => cell.persist(Object.freeze({ ...queued, lowered_input }) as typeof queued)); + } + assert.equal(accessed, false); cell.abort(); assert.deepEqual(journal.snapshot().cells, []); +}); + +test("normalizes stable snapshots and restores only matching reserved principals", () => { + const source = populated(); const snapshot = source.snapshot(); + const matching = createWorldActionJournal(); matching.reservePrincipals(["p"]); matching.restore(snapshot); + assert.deepEqual(matching.snapshot(), snapshot); + const mismatch = createWorldActionJournal(); mismatch.reservePrincipals(["other"]); + assert.throws(() => mismatch.restore(snapshot)); + const altered = structuredClone(snapshot); (altered.cells as unknown[]).reverse(); (altered.lanes as unknown[]).reverse(); + const normalized = createWorldActionJournal(); normalized.restore(altered); + assert.deepEqual(normalized.snapshot(), snapshot); +}); + +test("terminal audit capacity is immutable, denied-only, and closes after denial", () => { + const journal = createWorldActionJournal(); journal.reservePrincipals(["p"]); + for (let index = 0; index < 9_999; index += 1) journal.audit("p", "denied"); + const terminal = journal.reserveAudit("p"); + assert.equal(terminal.terminal_capacity, true); + assert.throws(() => terminal.commit("queued")); + terminal.commit("denied"); + assert.throws(() => journal.reserveAudit("p")); + assert.throws(() => journal.audit("p", "denied")); +}); + +test("settles a pre-close outer audit after a terminal inner denial", () => { + const journal = createWorldActionJournal(); journal.reservePrincipals(["p"]); + for (let index = 0; index < 9_998; index += 1) journal.audit("p", "denied"); + const outer = journal.reserveAudit("p"); + const terminal = journal.reserveAudit("p"); + assert.equal(outer.terminal_capacity, false); assert.equal(terminal.terminal_capacity, true); + terminal.commit("denied"); + assert.throws(() => journal.reserveAudit("p")); + assert.throws(() => outer.commit("queued")); + outer.commit("denied"); + assert.throws(() => outer.commit("denied"), /stale action journal reservation/u); + assert.deepEqual(readWorldActionJournalStatus(journal), { closed: true, audit_count: 10_000, cell_count: 0 }); +}); diff --git a/src/world/actionJournal.ts b/src/world/actionJournal.ts new file mode 100644 index 0000000..0dbfb2e --- /dev/null +++ b/src/world/actionJournal.ts @@ -0,0 +1,321 @@ +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import type { QueuedWorldAction, WorldActionTerminal, WorldActQueuedReceipt } from "./actTypes.js"; +import { + cloneWorldActionJournalSnapshot, + parseQueuedWorldAction, + parseWorldActionJournalSnapshot, + parseWorldActionReceipt, + parseWorldActionTerminal, + type ActionJournalAudit, + type WorldActionJournalSnapshot, +} from "./actionJournalSnapshot.js"; + +export { WORLD_ACTION_JOURNAL_VERSION, type WorldActionJournalSnapshot } from "./actionJournalSnapshot.js"; + +type CellState = "reserved" | "provisional" | "prepared" | "authorized" | "terminal" | "aborted"; +type LiveCell = { + receipt?: WorldActQueuedReceipt; + sequence: number; + state: CellState; + record?: QueuedWorldAction; + terminal?: WorldActionTerminal; +}; + +export interface ActionJournalReservation { + persist(record: QueuedWorldAction): void; + prepareAuthorization(): void; + authorize(): void; + abort(): void; +} +export interface ActionAuditReservation { + readonly terminal_capacity: boolean; + commit(result: "queued" | "denied"): void; +} +/** One issued, all-or-nothing terminal write for a dynamics tick. */ +export interface WorldActionTerminalReservation { + readonly queued: readonly QueuedWorldAction[]; + abort(): void; + commit(records: readonly WorldActionTerminal[]): void; +} +export interface WorldActionJournal { + reservePrincipals(principals: readonly string[]): void; + reserveAudit(principal: string): ActionAuditReservation; + audit(principal: string, result: "queued" | "denied"): void; + reserve(receipt: WorldActQueuedReceipt, sequence: number): ActionJournalReservation; + pending(tick: number): readonly QueuedWorldAction[]; + reserveTerminals(tick: number): WorldActionTerminalReservation; + terminal(record: WorldActionTerminal): void; + project(record: WorldActionTerminal): void; + close(): void; + snapshot(): WorldActionJournalSnapshot; + restore(input: unknown): void; +} +export interface WorldActionJournalStatus { readonly closed: boolean; readonly audit_count: number; readonly cell_count: number; } + +const issued = new WeakSet(); +const statusReaders = new WeakMap WorldActionJournalStatus>(); +const frozen = (value: Value): Value => Object.freeze(value); +const validPrincipal = (value: unknown): value is string => typeof value === "string" && value.length > 0 + && value.length <= 256 && value === value.trim(); +const closedError = (): Error => new Error("world action journal closed"); +const stale = (): never => { throw new Error("stale action journal reservation"); }; +const invalid = (): never => { throw new Error("invalid action journal reservation"); }; + +export const readWorldActionJournal = (value: unknown): WorldActionJournal | undefined => + value !== null && typeof value === "object" && issued.has(value) ? value as WorldActionJournal : undefined; + +export const readWorldActionJournalStatus = (value: unknown): WorldActionJournalStatus | undefined => { + if (readWorldActionJournal(value) === undefined) return undefined; + return statusReaders.get(value as object)?.(); +}; + +export const createWorldActionJournal = (): WorldActionJournal => { + let lanes = new Map(); + let cells = new Map(); + let sequences = new Map(); + let audits: ActionJournalAudit[] = []; + let activeAudits = 0; + let activeAuditsByPrincipal = new Map(); + let closed = false; + let principalsReserved = false; + let restored = false; + let terminalReservation: { readonly cells: readonly LiveCell[]; invalidate(): void } | undefined; + + const close = (): void => { + terminalReservation?.invalidate(); + closed = true; + }; + const closeAndThrow = (): never => { close(); throw closedError(); }; + const quiescent = (): boolean => terminalReservation === undefined && activeAudits === 0 && [...cells.values()].every((cell) => + cell.state === "authorized" || cell.state === "terminal"); + const pristine = (): boolean => !restored && activeAudits === 0 && audits.length === 0 && cells.size === 0 && !closed; + const remove = (cell: LiveCell): void => { + const receipt = cell.receipt; + if (receipt !== undefined) cells.delete(receipt.receipt_id); + sequences.delete(cell.sequence); + cell.receipt = undefined; cell.record = undefined; cell.terminal = undefined; + cell.sequence = 0; cell.state = "aborted"; + }; + + const reserveAudit = (principal: string): ActionAuditReservation => { + if (!validPrincipal(principal) || !lanes.has(principal)) throw new Error("unknown action journal principal"); + if (closed) throw closedError(); + const count = lanes.get(principal)!; + const active = activeAuditsByPrincipal.get(principal) ?? 0; + if (count + active >= DYNAMICS_LIMITS.retained_action_records || audits.length + activeAudits >= DYNAMICS_LIMITS.retained_action_records) { + return closeAndThrow(); + } + const terminal_capacity = count + active === DYNAMICS_LIMITS.retained_action_records - 1 + || audits.length + activeAudits === DYNAMICS_LIMITS.retained_action_records - 1; + activeAudits += 1; activeAuditsByPrincipal.set(principal, active + 1); + let settled = false; + return frozen({ terminal_capacity, commit: (result: "queued" | "denied"): void => { + if (settled || (result !== "queued" && result !== "denied") || (terminal_capacity && result !== "denied")) stale(); + if (closed && result !== "denied") throw closedError(); + audits.push(frozen({ principal, result })); + lanes.set(principal, lanes.get(principal)! + 1); + activeAudits -= 1; + const remaining = (activeAuditsByPrincipal.get(principal) ?? 1) - 1; + if (remaining === 0) activeAuditsByPrincipal.delete(principal); + else activeAuditsByPrincipal.set(principal, remaining); + settled = true; + if (terminal_capacity) close(); + } }); + }; + + const reserve = (receiptInput: WorldActQueuedReceipt, sequence: number): ActionJournalReservation => { + if (closed || terminalReservation !== undefined) throw closedError(); + const receipt = parseWorldActionReceipt(receiptInput, sequence); + const safeReceipt: WorldActQueuedReceipt = receipt === undefined ? invalid() : receipt; + if (!Number.isSafeInteger(sequence) || sequence < 1) invalid(); + if (cells.size >= DYNAMICS_LIMITS.retained_action_records || cells.has(safeReceipt.receipt_id) || sequences.has(sequence)) { + return closeAndThrow(); + } + const cell: LiveCell = { receipt: safeReceipt, sequence, state: "reserved" }; + cells.set(safeReceipt.receipt_id, cell); sequences.set(sequence, safeReceipt.receipt_id); + let settled = false; + let authorize: () => void; + let abort: () => void; + const staleReservation = (): never => stale(); + const successfulAuthorization = (): void => { + cell.state = "authorized"; + settled = true; + authorize = staleReservation; + abort = staleReservation; + }; + const exactAbort = (): void => { + remove(cell); + settled = true; + authorize = staleReservation; + abort = staleReservation; + }; + const unpreparedAuthorization = (): never => { throw new Error("action authorization is not prepared"); }; + authorize = unpreparedAuthorization; + abort = exactAbort; + return frozen({ + persist: (record: QueuedWorldAction): void => { + if (settled || cell.state !== "reserved" || cell.receipt === undefined) invalid(); + const currentReceipt: WorldActQueuedReceipt = cell.receipt === undefined ? invalid() : cell.receipt; + const cloned = parseQueuedWorldAction(record, currentReceipt, sequence); + if (cloned === undefined || !lanes.has(cloned.principal)) invalid(); + cell.record = cloned; + cell.state = "provisional"; + }, + prepareAuthorization: (): void => { + if (settled || cell.state !== "provisional" || cell.record === undefined) invalid(); + cell.state = "prepared"; + authorize = successfulAuthorization; + }, + authorize: (): void => authorize(), + abort: (): void => abort(), + }); + }; + + const reserveTerminals = (tick: number): WorldActionTerminalReservation => { + if (closed || !Number.isSafeInteger(tick) || tick < 0 || !quiescent()) return closeAndThrow(); + const bound = [...cells.values()].filter((cell) => cell.state === "authorized" && cell.record?.at_tick === tick) + .sort((left, right) => left.sequence - right.sequence); + if (bound.length > DYNAMICS_LIMITS.retained_action_records || bound.some((cell) => cell.receipt === undefined || cell.record === undefined)) return closeAndThrow(); + const queued = bound.map((cell) => { + const record = parseQueuedWorldAction(cell.record!, cell.receipt!, cell.sequence); + return record === undefined ? closeAndThrow() : record; + }); + const reservation = { cells: bound, invalidate: (): void => {} }; + terminalReservation = reservation; + let settled = false; + const staleTerminal = (): never => stale(); + let abort: () => void; + let commit: (records: readonly WorldActionTerminal[]) => void; + const release = (): void => { + if (terminalReservation === reservation) terminalReservation = undefined; + settled = true; + abort = staleTerminal; + commit = staleTerminal; + }; + reservation.invalidate = release; + abort = () => { + if (settled || terminalReservation !== reservation) staleTerminal(); + release(); + }; + const boundBySequence = new Map(bound.map((cell) => [cell.sequence, cell])); + commit = (records) => { + if (settled || terminalReservation !== reservation || !Array.isArray(records) || records.length !== bound.length) return closeAndThrow(); + try { + const bySequence = new Map(); + const seen = new Set(); + for (const record of records) { + if (record === null || typeof record !== "object" || !Number.isSafeInteger(record.sequence) || seen.has(record.sequence)) return closeAndThrow(); + seen.add(record.sequence); + const cell = boundBySequence.get(record.sequence); + if (cell === undefined || cell.receipt === undefined || cell.record === undefined) return closeAndThrow(); + const terminal = parseWorldActionTerminal(record, cell.receipt, cell.sequence); + if (terminal === undefined || terminal.decision_id !== cell.record.decision_id) return closeAndThrow(); + bySequence.set(record.sequence, terminal); + } + if (bySequence.size !== bound.length) return closeAndThrow(); + const ordered: Array> = []; + for (let index = 0; index < bound.length; index += 1) { + const cell = bound[index]!; + const terminal = bySequence.get(cell.sequence); + if (terminal === undefined) return closeAndThrow(); + ordered.push({ cell, terminal }); + } + for (let index = 0; index < ordered.length; index += 1) { + ordered[index]!.cell.terminal = ordered[index]!.terminal; + ordered[index]!.cell.state = "terminal"; + } + } catch { + return closeAndThrow(); + } + release(); + }; + return frozen({ queued: frozen(queued), abort: (): void => abort(), commit: (records: readonly WorldActionTerminal[]): void => commit(records) }); + }; + + const stableSnapshot = (): WorldActionJournalSnapshot => { + if (!quiescent()) throw new Error("action journal snapshot requires quiescence"); + const snapshot = { + version: "simfile.world-action-journal.v1" as const, + closed, + lanes: [...lanes.entries()].map(([principal, count]) => ({ principal, count })), + audits, + cells: [...cells.values()].map((cell) => { + if ((cell.state !== "authorized" && cell.state !== "terminal") || cell.receipt === undefined || cell.record === undefined) { + throw new Error("action journal snapshot requires stable cells"); + } + return { receipt: cell.receipt, sequence: cell.sequence, state: cell.state, + record: cell.record, terminal: cell.terminal ?? null }; + }), + }; + return cloneWorldActionJournalSnapshot(snapshot); + }; + + const journal: WorldActionJournal = frozen({ + reservePrincipals: (principals: readonly string[]): void => { + if (!pristine() || principalsReserved || !Array.isArray(principals) || principals.length > DYNAMICS_LIMITS.retained_action_records + || principals.some((principal) => !validPrincipal(principal)) || new Set(principals).size !== principals.length) { + throw new Error("invalid action journal principals"); + } + for (const principal of principals) lanes.set(principal, 0); + principalsReserved = true; + }, + reserveAudit, + audit: (principal: string, result: "queued" | "denied"): void => { reserveAudit(principal).commit(result); }, + reserve, + pending: (tick: number): readonly QueuedWorldAction[] => { + if (terminalReservation !== undefined) throw new Error("action terminal reservation active"); + if (!Number.isSafeInteger(tick) || tick < 0) return frozen([]); + const pending: QueuedWorldAction[] = []; + for (const cell of cells.values()) { + if (cell.state !== "authorized" || cell.record === undefined || cell.record.at_tick !== tick || cell.receipt === undefined) continue; + const copy = parseQueuedWorldAction(cell.record, cell.receipt, cell.sequence); + if (copy === undefined) return closeAndThrow(); + pending.push(copy); + } + pending.sort((left, right) => left.dynamics_sequence - right.dynamics_sequence); + return frozen(pending); + }, + reserveTerminals, + terminal: (record: WorldActionTerminal): void => { + if (closed || terminalReservation !== undefined) throw closedError(); + const sequence = record.sequence; + const receiptId = Number.isSafeInteger(sequence) ? sequences.get(sequence) : undefined; + const cell = receiptId === undefined ? undefined : cells.get(receiptId); + if (cell === undefined || cell.state !== "authorized" || cell.receipt === undefined || cell.record === undefined) return closeAndThrow(); + const terminal = parseWorldActionTerminal(record, cell.receipt, cell.sequence); + if (terminal === undefined || terminal.decision_id !== cell.record.decision_id) return closeAndThrow(); + cell.terminal = terminal; cell.state = "terminal"; + }, + project: (record: WorldActionTerminal): void => { + if (closed || terminalReservation !== undefined) throw closedError(); + const sequence = record.sequence; + const receiptId = Number.isSafeInteger(sequence) ? sequences.get(sequence) : undefined; + const cell = receiptId === undefined ? undefined : cells.get(receiptId); + if (cell === undefined || cell.state !== "terminal" || cell.receipt === undefined || cell.terminal?.disposition !== "applied") return closeAndThrow(); + const terminal = parseWorldActionTerminal(record, cell.receipt, cell.sequence); + if (terminal === undefined || terminal.disposition !== "applied" || terminal.decision_id !== cell.terminal.decision_id) return closeAndThrow(); + cell.terminal = terminal; + }, + close, + snapshot: stableSnapshot, + restore: (input: unknown): void => { + if (!pristine()) throw new Error("action journal restore requires pristine journal"); + const snapshot = parseWorldActionJournalSnapshot(input); + if (snapshot === undefined) throw new Error("invalid action journal snapshot"); + const snapshotPrincipals = snapshot.lanes.map((lane) => lane.principal); + if (principalsReserved && (snapshotPrincipals.length !== lanes.size || snapshotPrincipals.some((principal) => !lanes.has(principal)))) { + throw new Error("action journal restore principal mismatch"); + } + lanes = new Map(snapshot.lanes.map((lane) => [lane.principal, lane.count])); + cells = new Map(snapshot.cells.map((cell) => [cell.receipt.receipt_id, { + receipt: cell.receipt, sequence: cell.sequence, state: cell.state, record: cell.record, + ...(cell.terminal === null ? {} : { terminal: cell.terminal }), + }])); + sequences = new Map(snapshot.cells.map((cell) => [cell.sequence, cell.receipt.receipt_id])); + audits = [...snapshot.audits]; closed = snapshot.closed; restored = true; principalsReserved = true; + }, + }); + issued.add(journal); + statusReaders.set(journal, () => Object.freeze({ closed, audit_count: audits.length, cell_count: cells.size })); + return journal; +}; diff --git a/src/world/actionJournalInspection.ts b/src/world/actionJournalInspection.ts new file mode 100644 index 0000000..2694d88 --- /dev/null +++ b/src/world/actionJournalInspection.ts @@ -0,0 +1,58 @@ +import { readWorldActionJournal, readWorldActionJournalStatus, type WorldActionJournal, type WorldActionJournalStatus } from "./actionJournal.js"; +import type { WorldActionJournalSnapshot } from "./actionJournalSnapshot.js"; +import { readWorldActionRefusalJournal, type WorldActionRefusalJournal, type WorldActionRefusalReadPort } from "./actionRefusalJournal.js"; + +export interface WorldActionJournalInspection { + snapshot(): WorldActionJournalSnapshot; + status(): WorldActionJournalStatus; +} + +const inspections = new WeakMap(); +const refusalInspections = new WeakMap(); + +export const registerWorldRuntimeActionJournalInspection = ( + runtime: object, + journal: WorldActionJournal, +): void => { + if (inspections.has(runtime)) throw new Error("world action journal inspection already issued"); + const issuedJournal = readWorldActionJournal(journal); + if (issuedJournal === undefined) throw new Error("world action journal inspection requires an issued journal"); + const inspection: WorldActionJournalInspection = Object.freeze({ + snapshot: () => issuedJournal.snapshot(), + status: () => { + const status = readWorldActionJournalStatus(issuedJournal); + if (status === undefined) throw new Error("world action journal inspection is unavailable"); + return status; + }, + }); + inspections.set(runtime, inspection); +}; + +export const readWorldRuntimeActionJournalInspection = ( + runtime: unknown, +): WorldActionJournalInspection | undefined => + runtime !== null && typeof runtime === "object" ? inspections.get(runtime) : undefined; + +export const registerWorldRuntimeActionRefusalJournalInspection = ( + runtime: object, + journal: WorldActionRefusalJournal, +): void => { + if (refusalInspections.has(runtime)) { + throw new Error("world action refusal inspection already issued"); + } + const issuedJournal = readWorldActionRefusalJournal(journal); + if (issuedJournal === undefined) { + throw new Error("world action refusal inspection requires an issued journal"); + } + refusalInspections.set(runtime, Object.freeze({ + acknowledge: (ordinal: number): void => issuedJournal.acknowledge(ordinal), + read: (afterOrdinal: number) => issuedJournal.read(afterOrdinal), + })); +}; + +export const readWorldRuntimeActionRefusalJournalInspection = ( + runtime: unknown, +): WorldActionRefusalReadPort | undefined => + runtime !== null && typeof runtime === "object" + ? refusalInspections.get(runtime) + : undefined; diff --git a/src/world/actionJournalLifecycle.test.ts b/src/world/actionJournalLifecycle.test.ts new file mode 100644 index 0000000..6212c2f --- /dev/null +++ b/src/world/actionJournalLifecycle.test.ts @@ -0,0 +1,178 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { createWorldActionJournal } from "./actionJournal.js"; + +const identity = { run_id: "run", world_id: "world", world_instance_id: "instance", manifest_digest: `sha256:${"a".repeat(64)}`, state_version: 0 } as const; +const receipt = (sequence: number) => ({ disposition: "queued" as const, receipt_id: `world-act-${sequence}`, decision_id: `decision-${String(sequence).padStart(12, "0")}`, identity, apply_tick: 0 }); +const queued = (sequence: number, changes: Record = {}) => ({ + receipt_id: `world-act-${sequence}`, decision_id: `decision-${String(sequence).padStart(12, "0")}`, principal: "p", + holder: "world://world/entity/p", affordance: "world://world/affordance/a", target: "world://world/entity/p", + at_tick: 0, dynamics_sequence: sequence, mechanics_action: "action", mechanics_actor: "object:p", mechanics_target: "object:p", + lowered_input: {}, identity, ...changes, +}); + +const reserved = (principals: readonly string[] = ["p"]) => { + const journal = createWorldActionJournal(); + journal.reservePrincipals(principals); + return journal; +}; +const authorized = (journal: ReturnType, sequence: number): void => { + journal.audit("p", "queued"); + const cell = journal.reserve(receipt(sequence), sequence); + cell.persist(queued(sequence)); + cell.prepareAuthorization(); + cell.authorize(); +}; +const terminal = (sequence: number) => Object.freeze({ + disposition: "applied" as const, receipt_id: `world-act-${sequence}`, + decision_id: `decision-${String(sequence).padStart(12, "0")}`, + sequence, apply_tick: 0, projection: "not_configured" as const, +}); + +test("requires reserved, provisional, prepared, then authorized in order", () => { + const journal = reserved(); + const cell = journal.reserve(receipt(1), 1); + assert.throws(() => cell.authorize()); + assert.throws(() => journal.snapshot()); + cell.persist(queued(1)); + assert.throws(() => cell.authorize()); + assert.throws(() => journal.snapshot()); + cell.prepareAuthorization(); + assert.throws(() => journal.snapshot()); + assert.doesNotThrow(() => cell.authorize()); + assert.equal(journal.pending(0).length, 1); + assert.throws(() => cell.authorize()); + assert.throws(() => cell.abort()); +}); + +test("abort is exact from every pre-authorized state and leaves no cell", () => { + for (const state of ["reserved", "provisional", "prepared"] as const) { + const journal = reserved(); + const cell = journal.reserve(receipt(1), 1); + if (state !== "reserved") cell.persist(queued(1)); + if (state === "prepared") cell.prepareAuthorization(); + assert.doesNotThrow(() => cell.abort()); + assert.equal(journal.snapshot().cells.length, 0); + assert.throws(() => cell.abort()); + assert.throws(() => cell.authorize()); + } +}); + +test("invalid receipts and every queued field reject without closing or retaining payload", () => { + const invalidReceipts = [ + undefined, {}, { ...receipt(1), disposition: "bad" }, { ...receipt(1), receipt_id: "other" }, + { ...receipt(1), decision_id: "other" }, { ...receipt(1), apply_tick: -1 }, + { ...receipt(1), identity: { ...identity, manifest_digest: "bad" } }, + ]; + for (const input of invalidReceipts) { + const journal = reserved(); + assert.throws(() => journal.reserve(input as never, 1)); + assert.equal(journal.snapshot().cells.length, 0); + journal.audit("p", "denied"); + } + + const invalidRecords = [ + ["receipt_id", "world-act-2"], ["decision_id", "decision-000000000002"], ["principal", "unknown"], + ["holder", "not-an-address"], ["affordance", "not-an-address"], ["target", "not-an-address"], + ["at_tick", 1], ["dynamics_sequence", 2], ["mechanics_action", ""], ["mechanics_actor", ""], + ["mechanics_target", ""], ["lowered_input", []], ["identity", { ...identity, world_id: "other" }], + ] as const; + for (const [field, value] of invalidRecords) { + const journal = reserved(); + const cell = journal.reserve(receipt(1), 1); + assert.throws(() => cell.persist(queued(1, { [field]: value }))); + assert.throws(() => journal.snapshot()); + cell.abort(); + assert.equal(journal.snapshot().cells.length, 0); + } +}); + +test("unknown principals do not allocate, while collisions and exhaustion close deterministically", () => { + const journal = reserved(["p", "q"]); + assert.throws(() => journal.reserveAudit("unknown")); + journal.audit("p", "denied"); + assert.equal(journal.snapshot().audits.length, 1); + + const receiptCollision = reserved(); + receiptCollision.reserve(receipt(1), 1); + assert.throws(() => receiptCollision.reserve(receipt(1), 2)); + assert.throws(() => receiptCollision.reserve(receipt(2), 3)); + + const sequenceCollision = reserved(); + sequenceCollision.reserve(receipt(1), 1); + assert.throws(() => sequenceCollision.reserve(receipt(2), 1)); + + const laneExhaustion = reserved(); + for (let index = 0; index < DYNAMICS_LIMITS.retained_action_records; index += 1) laneExhaustion.audit("p", "denied"); + assert.throws(() => laneExhaustion.reserveAudit("p")); + const globalExhaustion = reserved(["p", "q"]); + for (let index = 0; index < DYNAMICS_LIMITS.retained_action_records / 2; index += 1) { + globalExhaustion.audit("p", "denied"); + globalExhaustion.audit("q", "denied"); + } + assert.throws(() => globalExhaustion.reserveAudit("p")); + + const cells = reserved(); + for (let sequence = 1; sequence <= DYNAMICS_LIMITS.retained_action_records; sequence += 1) { + const cell = cells.reserve(receipt(sequence), sequence); + cell.persist(queued(sequence)); + cell.prepareAuthorization(); + cell.authorize(); + } + assert.throws(() => cells.reserve(receipt(DYNAMICS_LIMITS.retained_action_records + 1), DYNAMICS_LIMITS.retained_action_records + 1)); + assert.throws(() => cells.reserveAudit("p")); +}); + +test("closed journals reject new reservations and retain a stable closed snapshot", () => { + const journal = reserved(); + journal.close(); + assert.throws(() => journal.reserveAudit("p")); + assert.throws(() => journal.reserve(receipt(1), 1)); + assert.deepEqual(journal.snapshot(), { version: "simfile.world-action-journal.v1", closed: true, lanes: [{ principal: "p", count: 0 }], audits: [], cells: [] }); +}); + +test("binds cloned ordered terminal batches and aborts only without terminal facts", () => { + const journal = reserved(); + authorized(journal, 2); authorized(journal, 1); + const batch = journal.reserveTerminals(0); + assert.deepEqual(batch.queued.map((entry) => entry.dynamics_sequence), [1, 2]); + assert(Object.isFrozen(batch.queued)); assert(Object.isFrozen(batch.queued[0]!)); + assert.throws(() => journal.pending(0)); assert.throws(() => journal.snapshot()); + batch.abort(); + assert.equal(journal.pending(0).length, 2); + assert.equal(journal.snapshot().cells.every((cell) => cell.terminal === null), true); + assert.throws(() => batch.abort()); +}); + +test("commits terminal batches all-or-nothing and closes impossible joins", () => { + for (const records of [ + [terminal(1)], [terminal(1), terminal(1)], [terminal(1), terminal(99)], + [terminal(1), { ...terminal(2), decision_id: "decision-000000000001" }], + ]) { + const journal = reserved(); authorized(journal, 1); authorized(journal, 2); + const batch = journal.reserveTerminals(0); + assert.throws(() => batch.commit(records as never)); + assert.throws(() => journal.reserveAudit("p")); + const snapshot = journal.snapshot(); + assert.equal(snapshot.closed, true); + assert.deepEqual(snapshot.cells.map((cell) => [cell.state, cell.terminal]), [["authorized", null], ["authorized", null]]); + assert.throws(() => batch.abort()); + assert.throws(() => batch.commit(records as never)); + } + const journal = reserved(); authorized(journal, 1); authorized(journal, 2); + const batch = journal.reserveTerminals(0); + batch.commit([terminal(2), terminal(1)]); + assert.deepEqual(journal.snapshot().cells.map((cell) => cell.terminal?.sequence), [1, 2]); + assert.throws(() => batch.commit([terminal(1), terminal(2)])); +}); + +test("closing an active terminal reservation releases it and invalidates both handles", () => { + const journal = reserved(); authorized(journal, 1); + const batch = journal.reserveTerminals(0); + journal.close(); + assert.deepEqual(journal.snapshot().cells.map((cell) => [cell.state, cell.terminal]), [["authorized", null]]); + assert.equal(journal.snapshot().closed, true); + assert.throws(() => batch.abort()); + assert.throws(() => batch.commit([terminal(1)])); +}); diff --git a/src/world/actionJournalSnapshot.test.ts b/src/world/actionJournalSnapshot.test.ts new file mode 100644 index 0000000..c7173f6 --- /dev/null +++ b/src/world/actionJournalSnapshot.test.ts @@ -0,0 +1,213 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWorldActionJournal } from "./actionJournal.js"; +import type { WorldActionJournalSnapshot } from "./actionJournalSnapshot.js"; + +const identity = (tick = 0) => ({ + run_id: "run", world_id: "world", world_instance_id: "instance", + manifest_digest: "sha256:aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + state_version: tick, +}); + +const receipt = (sequence: number, tick = 0) => ({ + disposition: "queued" as const, receipt_id: `world-act-${sequence}`, + decision_id: `decision-${String(sequence).padStart(12, "0")}`, identity: identity(tick), apply_tick: tick, +}); + +const queued = (sequence: number, principal = "p", tick = 0) => ({ + receipt_id: `world-act-${sequence}`, decision_id: `decision-${String(sequence).padStart(12, "0")}`, + principal, holder: "world://world/entity/p", affordance: "world://world/affordance/a", + target: "world://world/entity/p", at_tick: tick, dynamics_sequence: sequence, + mechanics_action: "action", mechanics_actor: "object:p", mechanics_target: "object:p", + lowered_input: { nested: { value: 1 } }, identity: identity(tick), +}); + +const authorized = (withAudit = true) => { + const journal = createWorldActionJournal(); + journal.reservePrincipals(["p", "q"]); + if (withAudit) journal.audit("p", "queued"); + const cell = journal.reserve(receipt(1), 1); + cell.persist(queued(1)); + cell.prepareAuthorization(); + cell.authorize(); + return journal; +}; + +const terminal = (journal: ReturnType, projection: "not_configured" | "projected" | "failed", disposition: "applied" | "rejected_at_mechanics" = "applied") => { + journal.terminal({ + disposition, receipt_id: "world-act-1", decision_id: "decision-000000000001", sequence: 1, apply_tick: 0, + projection: "not_configured", ...(disposition === "rejected_at_mechanics" ? { public_code: "blocked" } : {}), + }); + if (projection !== "not_configured") journal.project({ + disposition, receipt_id: "world-act-1", decision_id: "decision-000000000001", sequence: 1, apply_tick: 0, + projection, ...(projection === "projected" ? { effect: { outcome: "ok" } } : {}), + ...(disposition === "rejected_at_mechanics" ? { public_code: "blocked" } : {}), + }); + return journal; +}; + +const emptySnapshot = (): WorldActionJournalSnapshot => createWorldActionJournal().snapshot(); +const altered = (snapshot: WorldActionJournalSnapshot): Record => structuredClone(snapshot) as Record; +const rejectsPristine = (candidate: unknown): void => { + const target = createWorldActionJournal(); + assert.throws(() => target.restore(candidate)); + assert.deepEqual(target.snapshot(), emptySnapshot()); +}; + +test("round trips every stable state and continues after restore", () => { + const denied = createWorldActionJournal(); + denied.reservePrincipals(["p", "q"]); + denied.audit("p", "denied"); + const snapshots = [ + emptySnapshot(), denied.snapshot(), authorized().snapshot(), + terminal(authorized(), "not_configured", "rejected_at_mechanics").snapshot(), + terminal(authorized(), "not_configured").snapshot(), + terminal(authorized(), "projected").snapshot(), + terminal(authorized(), "failed").snapshot(), + ]; + for (const snapshot of snapshots) { + const restored = createWorldActionJournal(); + restored.restore(snapshot); + assert.deepEqual(restored.snapshot(), snapshot); + assert.equal(restored.snapshot().cells.length, snapshot.cells.length); + } + + const pending = createWorldActionJournal(); + pending.restore(authorized().snapshot()); + assert.equal(pending.pending(0).length, 1); + pending.terminal({ disposition: "applied", receipt_id: "world-act-1", decision_id: "decision-000000000001", sequence: 1, apply_tick: 0, projection: "not_configured" }); + pending.project({ disposition: "applied", receipt_id: "world-act-1", decision_id: "decision-000000000001", sequence: 1, apply_tick: 0, projection: "projected", effect: { outcome: "continued" } }); + assert.equal(pending.snapshot().cells[0]!.terminal?.projection, "projected"); + pending.close(); + assert.throws(() => pending.reserveAudit("p")); +}); + +test("canonicalizes lanes, audits, and cells while preserving all counts", () => { + const source = createWorldActionJournal(); + source.reservePrincipals(["q", "p"]); + source.audit("p", "denied"); + source.audit("p", "queued"); + const cell = source.reserve(receipt(1), 1); + cell.persist(queued(1)); + cell.prepareAuthorization(); + cell.authorize(); + const candidate = altered(source.snapshot()); + candidate.lanes.reverse(); + candidate.audits.reverse(); + candidate.cells.reverse(); + const restored = createWorldActionJournal(); + restored.restore(candidate); + assert.deepEqual(restored.snapshot(), source.snapshot()); + assert.deepEqual(restored.snapshot().lanes.map((lane) => lane.principal), ["p", "q"]); + assert.deepEqual(restored.snapshot().audits.map((audit) => [audit.principal, audit.result]), [["p", "denied"], ["p", "queued"]]); +}); + +test("rejects authority smuggling in restored lowered input and projected effects", () => { + for (const field of ["token", "decision_id", "principal", "receipt", "receipt_id", "audit", "effect", "tick", "origin", "run_id", "world_id"]) { + const candidate = altered(authorized().snapshot()); + candidate.cells[0].record.lowered_input = { nested: { [field]: "smuggled" } }; + assert.equal(candidate.cells[0].record.lowered_input.nested[field], "smuggled"); + rejectsPristine(candidate); + } + for (const field of ["token", "decision_id", "principal", "receipt", "receipt_id", "audit", "effect", "tick", "origin", "run_id", "world_id"]) { + const candidate = altered(terminal(authorized(), "projected").snapshot()); + candidate.cells[0].terminal.effect = { nested: { [field]: "smuggled" } }; + assert.equal(candidate.cells[0].terminal.effect.nested[field], "smuggled"); + rejectsPristine(candidate); + } +}); + +test("rejects root, cell, relation, and transient-state mutations without changing target", () => { + const snapshot = terminal(authorized(), "projected").snapshot(); + const mutations: Array<(candidate: Record) => void> = [ + (value) => { delete value.version; }, + (value) => { value.version = "other"; }, + (value) => { value.extra = true; }, + (value) => { delete value.lanes; }, + (value) => { value.lanes[0].count = 0; }, + (value) => { value.lanes.push({ principal: "p", count: 0 }); }, + (value) => { value.audits[0].result = "other"; }, + (value) => { value.audits.push({ principal: "p", result: "queued" }); }, + (value) => { value.cells[0].state = "prepared"; }, + (value) => { value.cells[0].sequence = 2; }, + (value) => { value.cells[0].receipt.receipt_id = "world-act-2"; }, + (value) => { value.cells[0].receipt.decision_id = "decision-000000000002"; }, + (value) => { value.cells[0].receipt.apply_tick = 1; }, + (value) => { value.cells[0].receipt.identity.world_id = "other"; }, + (value) => { value.cells[0].record.principal = "q"; }, + (value) => { value.cells[0].record.dynamics_sequence = 2; }, + (value) => { value.cells[0].record.identity.run_id = "other"; }, + (value) => { value.cells[0].terminal.decision_id = "decision-000000000002"; }, + (value) => { value.cells[0].terminal.sequence = 2; }, + (value) => { value.cells[0].terminal.apply_tick = 1; }, + (value) => { value.cells[0].terminal.projection = "bad"; }, + ]; + for (const mutate of mutations) { + const candidate = altered(snapshot); + mutate(candidate); + rejectsPristine(candidate); + } + const orphan = altered(emptySnapshot()); + orphan.lanes = [{ principal: "p", count: 1 }]; + orphan.audits = [{ principal: "p", result: "queued" }]; + rejectsPristine(orphan); + const duplicate = altered(snapshot); + duplicate.cells.push(structuredClone(duplicate.cells[0])); + rejectsPristine(duplicate); +}); + +test("rejects proxy, accessor, alias, bounds, and transient snapshot input", () => { + const snapshot = authorized().snapshot(); + let traps = 0; + const proxy = new Proxy(snapshot, { get: () => { traps += 1; return undefined; } }); + rejectsPristine(proxy); + assert.equal(traps, 0); + const accessor = altered(snapshot); + Object.defineProperty(accessor, "closed", { enumerable: true, get: () => { traps += 1; return false; } }); + rejectsPristine(accessor); + assert.equal(traps, 0); + const alias = altered(snapshot); + alias.cells.push(alias.cells[0]); + rejectsPristine(alias); + const tooMany = altered(snapshot); + tooMany.cells = new Array(10_001); + Object.defineProperty(tooMany.cells, "0", { enumerable: true, get: () => { traps += 1; throw new Error("must not read"); } }); + rejectsPristine(tooMany); + assert.equal(traps, 0); +}); + +test("parses a reachable aggregate history above the generic JSON graph budget", () => { + const journal = createWorldActionJournal(); + const principals = Array.from({ length: 10_000 }, (_, index) => `p${String(index).padStart(5, "0")}`); + journal.reservePrincipals(principals); + const cell = journal.reserve(receipt(1), 1); cell.persist(queued(1, principals[0]!)); cell.prepareAuthorization(); cell.authorize(); + journal.audit(principals[0]!, "queued"); + for (let index = 1; index < principals.length; index += 1) journal.audit(principals[index]!, "denied"); + const snapshot = journal.snapshot(); + assert.equal(snapshot.lanes.length, 10_000); + assert.equal(snapshot.audits.length, 10_000); + assert.equal(snapshot.audits.filter((audit) => audit.result === "queued").length, 1); + assert.equal(snapshot.cells.length, 1); + const restored = createWorldActionJournal(); restored.restore(snapshot); + assert.deepEqual(restored.snapshot(), snapshot); +}); + +test("restores exactly 10,000 independently valid terminal cells", () => { + const base = terminal(authorized(), "not_configured").snapshot(); + const cells = Array.from({ length: 10_000 }, (_, offset) => { + const sequence = offset + 1; const cell = structuredClone(base.cells[0]!) as any; + cell.sequence = sequence; cell.receipt.receipt_id = `world-act-${sequence}`; + cell.receipt.decision_id = `decision-${String(sequence).padStart(12, "0")}`; + cell.record.receipt_id = cell.receipt.receipt_id; cell.record.decision_id = cell.receipt.decision_id; + cell.record.dynamics_sequence = sequence; cell.terminal.receipt_id = cell.receipt.receipt_id; + cell.terminal.decision_id = cell.receipt.decision_id; cell.terminal.sequence = sequence; + return cell; + }); + const candidate = { ...base, lanes: [{ principal: "p", count: 10_000 }], + audits: Array.from({ length: 10_000 }, () => ({ principal: "p", result: "queued" as const })), cells }; + const restored = createWorldActionJournal(); restored.restore(candidate); + const snapshot = restored.snapshot(); + assert.equal(snapshot.cells.length, 10_000); + assert.equal(snapshot.cells.every((cell) => cell.state === "terminal" && cell.terminal?.disposition === "applied"), true); + assert.equal(restored.pending(0).length, 0); +}); diff --git a/src/world/actionJournalSnapshot.ts b/src/world/actionJournalSnapshot.ts new file mode 100644 index 0000000..62a8992 --- /dev/null +++ b/src/world/actionJournalSnapshot.ts @@ -0,0 +1,270 @@ +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import type { ReadonlyDynamicsJsonValue } from "../dynamics/types.js"; +import type { QueuedWorldAction, WorldActionTerminal, WorldActQueuedReceipt } from "./actTypes.js"; +import { copyHostileJson, type HostileJson } from "./hostileJson.js"; +import { types } from "node:util"; +import { assertNoWorldAuthorityFields } from "../world-surface/authority.js"; + +export const WORLD_ACTION_JOURNAL_VERSION = "simfile.world-action-journal.v1" as const; + +export type ActionJournalAudit = Readonly<{ principal: string; result: "queued" | "denied" }>; +export type ActionJournalCellState = "authorized" | "terminal"; +export interface ActionJournalSnapshotCell { + readonly receipt: WorldActQueuedReceipt; + readonly sequence: number; + readonly state: ActionJournalCellState; + readonly record: QueuedWorldAction; + readonly terminal: WorldActionTerminal | null; +} +export interface WorldActionJournalSnapshot { + readonly version: typeof WORLD_ACTION_JOURNAL_VERSION; + readonly closed: boolean; + readonly lanes: readonly Readonly<{ principal: string; count: number }>[]; + readonly audits: readonly ActionJournalAudit[]; + readonly cells: readonly ActionJournalSnapshotCell[]; +} + +type JsonRecord = Readonly>; + +const JOURNAL_AUTHORITY_NAMES = new Set([ + "audit", "decision", "effect", "receipt", "run", "tick", "world", + "receipt_id", +]); + +const ADDRESS = /^world:\/\/(?:[a-z][a-z0-9]*(?:-[a-z0-9]+)*\/)+(?:entity|affordance)\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +const DECISION = /^decision-[0-9]{12}$/u; +const DIGEST = /^sha256:[a-f0-9]{64}$/u; +const RECEIPT = /^world-act-([1-9][0-9]*)$/u; +const binding = (value: unknown): value is string => typeof value === "string" && value.length > 0 + && value.length <= 256 && value === value.trim(); +const tick = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const sequence = (value: unknown): value is number => tick(value) && value >= 1; +const frozen = (value: Value): Value => Object.freeze(value); +const fail = (): undefined => undefined; +const compareUtf16 = (left: string, right: string): number => left === right ? 0 : left < right ? -1 : 1; +const INDEX = /^(?:0|[1-9][0-9]*)$/u; +const DANGEROUS = new Set(["__proto__", "constructor", "prototype"]); + +/* The journal can legitimately contain more JSON nodes than one generic value. + * This scanner establishes one descriptor-only graph boundary first; records are + * then copied under their own existing dynamics JSON budget. */ +const scan = (value: unknown, seen: Set, depth = 0): void => { + if (depth > DYNAMICS_LIMITS.json_depth || value === null || typeof value !== "object") return; + if (types.isProxy(value) || seen.has(value)) throw new TypeError("hostile journal snapshot"); + seen.add(value); + const arrayValue = Array.isArray(value); + const prototype = Object.getPrototypeOf(value); + if ((arrayValue && prototype !== Array.prototype) || (!arrayValue && prototype !== Object.prototype && prototype !== null)) throw new TypeError("hostile journal snapshot"); + for (let current: object | null = value; current !== null; current = Object.getPrototypeOf(current)) if (types.isProxy(current) || Object.getOwnPropertyDescriptor(current, "then") !== undefined) throw new TypeError("hostile journal snapshot"); + const keys = Reflect.ownKeys(value); + if (arrayValue) { + const length = Object.getOwnPropertyDescriptor(value, "length"); + if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || length.value < 0 || length.value > DYNAMICS_LIMITS.retained_action_records || keys.length !== length.value + 1 || keys.some((key) => typeof key !== "string" || (key !== "length" && (!INDEX.test(key) || Number(key) >= length.value)))) throw new TypeError("hostile journal snapshot"); + for (let index = 0; index < length.value; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); if (!descriptor?.enumerable || !("value" in descriptor)) throw new TypeError("hostile journal snapshot"); scan(descriptor.value, seen, depth + 1); } + return; + } + for (const key of keys) { if (typeof key !== "string" || DANGEROUS.has(key)) throw new TypeError("hostile journal snapshot"); const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor?.enumerable || !("value" in descriptor)) throw new TypeError("hostile journal snapshot"); scan(descriptor.value, seen, depth + 1); } +}; +const outer = (value: unknown): readonly unknown[] => { + if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype) throw new TypeError("hostile journal snapshot"); + const length = Object.getOwnPropertyDescriptor(value, "length"); + if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || length.value < 0 || length.value > DYNAMICS_LIMITS.retained_action_records) throw new TypeError("hostile journal snapshot"); + const keys = Reflect.ownKeys(value); + if (keys.length !== length.value + 1 || keys.some((key) => typeof key !== "string" || (key !== "length" && (!INDEX.test(key) || Number(key) >= length.value)))) throw new TypeError("hostile journal snapshot"); + const result: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); if (!descriptor?.enumerable || !("value" in descriptor)) throw new TypeError("hostile journal snapshot"); result.push(descriptor.value); } + return result; +}; +const journalBoundary = (input: unknown): HostileJson => { + if (input === null || typeof input !== "object" || Array.isArray(input) || types.isProxy(input) || Object.getPrototypeOf(input) !== Object.prototype) throw new TypeError("hostile journal snapshot"); + const keys = Reflect.ownKeys(input); + const expected = ["version", "closed", "lanes", "audits", "cells"]; + if (keys.length !== expected.length || keys.some((key) => typeof key !== "string" || !expected.includes(key))) throw new TypeError("hostile journal snapshot"); + const root: Record = Object.create(null); + const seen = new Set(); scan(input, seen); + for (const key of expected) { const descriptor = Object.getOwnPropertyDescriptor(input, key); if (!descriptor?.enumerable || !("value" in descriptor)) throw new TypeError("hostile journal snapshot"); if (key === "lanes" || key === "audits" || key === "cells") root[key] = frozen(outer(descriptor.value).map((entry) => copyHostileJson(entry))); else root[key] = copyHostileJson(descriptor.value); } + return frozen(root); +}; + +const assertNoJournalAuthorityNames = (value: HostileJson, path: string): void => { + if (value === null || typeof value !== "object") return; + if (Array.isArray(value)) { + value.forEach((entry, index) => assertNoJournalAuthorityNames(entry, `${path}[${index}]`)); + return; + } + for (const [key, child] of Object.entries(value)) { + if (JOURNAL_AUTHORITY_NAMES.has(key)) throw new TypeError(`${path}.${key} is reserved for journal authority`); + assertNoJournalAuthorityNames(child, `${path}.${key}`); + } +}; + +const object = (value: HostileJson, fields: readonly string[]): JsonRecord | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value)) return fail(); + const keys = Object.keys(value); + return keys.length === fields.length && fields.every((field) => Object.hasOwn(value, field)) ? value as JsonRecord : fail(); +}; + +const array = (value: HostileJson): readonly HostileJson[] | undefined => Array.isArray(value) ? value : fail(); +const jsonObject = (value: HostileJson): Readonly> | undefined => + value !== null && typeof value === "object" && !Array.isArray(value) ? value as JsonRecord : fail(); + +const canonicalAddress = (value: unknown): value is string => typeof value === "string" && ADDRESS.test(value); +const canonicalReceipt = (value: unknown, expectedSequence?: number): value is string => { + if (typeof value !== "string") return false; + const match = RECEIPT.exec(value); + return match !== null && (expectedSequence === undefined || Number(match[1]) === expectedSequence); +}; + +const parseIdentity = (value: HostileJson): WorldActQueuedReceipt["identity"] | undefined => { + const item = object(value, ["run_id", "world_id", "world_instance_id", "manifest_digest", "state_version"]); + if (item === undefined || !binding(item.run_id) || !binding(item.world_id) || !binding(item.world_instance_id) + || typeof item.manifest_digest !== "string" || !DIGEST.test(item.manifest_digest) || !tick(item.state_version)) return fail(); + return frozen({ run_id: item.run_id, world_id: item.world_id, world_instance_id: item.world_instance_id, + manifest_digest: item.manifest_digest, state_version: item.state_version }); +}; + +const sameIdentity = (left: WorldActQueuedReceipt["identity"], right: WorldActQueuedReceipt["identity"]): boolean => + left.run_id === right.run_id && left.world_id === right.world_id && left.world_instance_id === right.world_instance_id + && left.manifest_digest === right.manifest_digest && left.state_version === right.state_version; + +const parseReceipt = (value: HostileJson, expectedSequence?: number): WorldActQueuedReceipt | undefined => { + const item = object(value, ["disposition", "receipt_id", "decision_id", "identity", "apply_tick"]); + const identity = item === undefined ? undefined : parseIdentity(item.identity); + if (item === undefined || item.disposition !== "queued" || !canonicalReceipt(item.receipt_id, expectedSequence) + || typeof item.decision_id !== "string" || !DECISION.test(item.decision_id) || !tick(item.apply_tick) || identity === undefined) return fail(); + return frozen({ disposition: "queued", receipt_id: item.receipt_id, decision_id: item.decision_id, + identity, apply_tick: item.apply_tick }); +}; + +const parseQueued = (value: HostileJson, receipt: WorldActQueuedReceipt, expectedSequence: number): QueuedWorldAction | undefined => { + const fields = ["receipt_id", "decision_id", "principal", "holder", "affordance", "target", "at_tick", + "dynamics_sequence", "mechanics_action", "mechanics_actor", "mechanics_target", "lowered_input", "identity"]; + const item = object(value, fields); const identity = item === undefined ? undefined : parseIdentity(item.identity); + const lowered = item === undefined ? undefined : jsonObject(item.lowered_input); + if (lowered !== undefined) { + assertNoWorldAuthorityFields(lowered as ReadonlyDynamicsJsonValue, "restored lowered mechanics input"); + assertNoJournalAuthorityNames(lowered, "restored lowered mechanics input"); + } + if (item === undefined || item.receipt_id !== receipt.receipt_id || item.decision_id !== receipt.decision_id + || !binding(item.principal) || !canonicalAddress(item.holder) || !canonicalAddress(item.affordance) + || !canonicalAddress(item.target) || !tick(item.at_tick) || item.at_tick !== receipt.apply_tick + || item.dynamics_sequence !== expectedSequence || !binding(item.mechanics_action) || !binding(item.mechanics_actor) + || !binding(item.mechanics_target) || lowered === undefined || identity === undefined || !sameIdentity(identity, receipt.identity)) return fail(); + return frozen({ receipt_id: receipt.receipt_id, decision_id: receipt.decision_id, principal: item.principal, + holder: item.holder, affordance: item.affordance, target: item.target, at_tick: item.at_tick, + dynamics_sequence: expectedSequence, mechanics_action: item.mechanics_action, mechanics_actor: item.mechanics_actor, + mechanics_target: item.mechanics_target, lowered_input: lowered, identity }); +}; + +const parseTerminal = (value: HostileJson, receipt: WorldActQueuedReceipt, expectedSequence: number): WorldActionTerminal | undefined => { + const base = ["disposition", "receipt_id", "decision_id", "sequence", "apply_tick", "projection"]; + if (value === null || typeof value !== "object" || Array.isArray(value)) return fail(); + const raw = value as JsonRecord; + const projected = raw.projection === "projected"; + const rejected = raw.disposition === "rejected_at_mechanics"; + const fields = [...base, ...(projected ? ["effect"] : []), ...(rejected && Object.hasOwn(raw, "public_code") ? ["public_code"] : [])]; + const item = object(value, fields); + if (item === undefined || (item.disposition !== "applied" && item.disposition !== "rejected_at_mechanics") + || item.receipt_id !== receipt.receipt_id || item.decision_id !== receipt.decision_id || item.sequence !== expectedSequence + || item.apply_tick !== receipt.apply_tick || (item.projection !== "not_configured" && item.projection !== "projected" && item.projection !== "failed") + || (item.disposition === "rejected_at_mechanics" && item.projection !== "not_configured") + || (item.disposition === "applied" && Object.hasOwn(item, "public_code")) + || (Object.hasOwn(item, "public_code") && !binding(item.public_code))) return fail(); + const effect = projected ? jsonObject(item.effect) : undefined; + if (effect !== undefined) { + assertNoWorldAuthorityFields(effect as ReadonlyDynamicsJsonValue, "restored projected effect"); + assertNoJournalAuthorityNames(effect, "restored projected effect"); + } + if (projected && (effect === undefined || item.disposition !== "applied")) return fail(); + return frozen({ disposition: item.disposition, receipt_id: receipt.receipt_id, decision_id: receipt.decision_id, + sequence: expectedSequence, apply_tick: receipt.apply_tick, projection: item.projection, + ...(item.public_code === undefined ? {} : { public_code: item.public_code as string }), ...(effect === undefined ? {} : { effect: effect as WorldActionTerminal["effect"] }) }); +}; + +const parseSnapshot = (root: HostileJson): WorldActionJournalSnapshot | undefined => { + const item = object(root, ["version", "closed", "lanes", "audits", "cells"]); + const laneValues = item === undefined ? undefined : array(item.lanes); + const auditValues = item === undefined ? undefined : array(item.audits); + const cellValues = item === undefined ? undefined : array(item.cells); + if (item === undefined || item.version !== WORLD_ACTION_JOURNAL_VERSION || typeof item.closed !== "boolean" + || laneValues === undefined || auditValues === undefined || cellValues === undefined + || laneValues.length > DYNAMICS_LIMITS.retained_action_records || auditValues.length > DYNAMICS_LIMITS.retained_action_records + || cellValues.length > DYNAMICS_LIMITS.retained_action_records) return fail(); + const lanes: Array> = []; + const laneSet = new Set(); + for (const value of laneValues) { + const lane = object(value, ["principal", "count"]); + const principal = lane?.principal; const count = lane?.count; + if (!binding(principal) || typeof count !== "number" || !Number.isSafeInteger(count) || count < 0 + || count > DYNAMICS_LIMITS.retained_action_records || laneSet.has(principal)) return fail(); + laneSet.add(principal); lanes.push(frozen({ principal, count })); + } + lanes.sort((left, right) => left.principal < right.principal ? -1 : left.principal > right.principal ? 1 : 0); + const audits: ActionJournalAudit[] = []; + const auditCounts = new Map(); + const queuedAuditCounts = new Map(); + for (const value of auditValues) { + const audit = object(value, ["principal", "result"]); + const principal = audit?.principal; const result = audit?.result; + if (!binding(principal) || !laneSet.has(principal) || (result !== "queued" && result !== "denied")) return fail(); + audits.push(frozen({ principal, result })); + auditCounts.set(principal, (auditCounts.get(principal) ?? 0) + 1); + if (result === "queued") queuedAuditCounts.set(principal, (queuedAuditCounts.get(principal) ?? 0) + 1); + } + audits.sort((left, right) => compareUtf16(left.principal, right.principal) + || compareUtf16(left.result, right.result)); + if (lanes.some((lane) => (auditCounts.get(lane.principal) ?? 0) !== lane.count)) return fail(); + const cells: ActionJournalSnapshotCell[] = []; + const receipts = new Set(); const sequences = new Set(); + for (const value of cellValues) { + const cell = object(value, ["receipt", "sequence", "state", "record", "terminal"]); + if (cell === undefined || !sequence(cell.sequence) || receipts.has((cell.receipt as JsonRecord)?.receipt_id as string) + || sequences.has(cell.sequence) || (cell.state !== "authorized" && cell.state !== "terminal")) return fail(); + const receipt = parseReceipt(cell.receipt, cell.sequence); + const record = receipt === undefined ? undefined : parseQueued(cell.record, receipt, cell.sequence); + const terminal = cell.terminal === null ? null : receipt === undefined ? undefined : parseTerminal(cell.terminal, receipt, cell.sequence); + if (receipt === undefined || record === undefined || terminal === undefined || (cell.state === "authorized" && terminal !== null) + || (cell.state === "terminal" && terminal === null) || !laneSet.has(record.principal)) return fail(); + receipts.add(receipt.receipt_id); sequences.add(cell.sequence); + cells.push(frozen({ receipt, sequence: cell.sequence, state: cell.state, record, terminal })); + } + cells.sort((left, right) => left.sequence - right.sequence); + const cellCounts = new Map(); + for (const cell of cells) cellCounts.set(cell.record.principal, (cellCounts.get(cell.record.principal) ?? 0) + 1); + for (const lane of lanes) { + const queuedAudits = queuedAuditCounts.get(lane.principal) ?? 0; + if (queuedAudits !== (cellCounts.get(lane.principal) ?? 0)) return fail(); + } + return frozen({ version: WORLD_ACTION_JOURNAL_VERSION, closed: item.closed, lanes: frozen(lanes), audits: frozen(audits), cells: frozen(cells) }); +}; + +/** Bounded outer lanes are scanned before any element is touched; records copy independently. */ +export const parseWorldActionJournalSnapshot = (input: unknown): WorldActionJournalSnapshot | undefined => { + try { return parseSnapshot(journalBoundary(input)); } catch { return undefined; } +}; + +export const parseWorldActionReceipt = (input: unknown, expectedSequence?: number): WorldActQueuedReceipt | undefined => { + try { return parseReceipt(copyHostileJson(input), expectedSequence); } catch { return undefined; } +}; + +export const parseQueuedWorldAction = ( + input: unknown, + receipt: WorldActQueuedReceipt, + expectedSequence: number, +): QueuedWorldAction | undefined => { + try { return parseQueued(copyHostileJson(input), receipt, expectedSequence); } catch { return undefined; } +}; + +export const parseWorldActionTerminal = ( + input: unknown, + receipt: WorldActQueuedReceipt, + expectedSequence: number, +): WorldActionTerminal | undefined => { + try { return parseTerminal(copyHostileJson(input), receipt, expectedSequence); } catch { return undefined; } +}; + +export const cloneWorldActionJournalSnapshot = (input: WorldActionJournalSnapshot): WorldActionJournalSnapshot => { + const parsed = parseWorldActionJournalSnapshot(input); + if (parsed === undefined) throw new TypeError("invalid world action journal snapshot"); + return parsed; +}; diff --git a/src/world/actionRefusalJournal.test.ts b/src/world/actionRefusalJournal.test.ts new file mode 100644 index 0000000..3687b06 --- /dev/null +++ b/src/world/actionRefusalJournal.test.ts @@ -0,0 +1,155 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createWorldActionRefusalJournal, +} from "./actionRefusalJournal.js"; +import { + readWorldRuntimeActionRefusalJournalInspection, +} from "./actionJournalInspection.js"; +import { + runtimeActEnvelope, + runtimeFixtureWithHooks, +} from "./runtime.test-helper.js"; + +const journal = (capacity = 4) => createWorldActionRefusalJournal({ + capacity, + principals: ["principal-red"], + readTick: () => 7, +}); + +test("the record-time choke point degrades forged rejection detail", () => { + const refusals = journal(); + assert.deepEqual( + refusals.refuse("principal-red", "forged_reason", "force"), + { + disposition: "rejected_at_ingress", + code: "world_action_denied", + reason: "internal_error", + }, + ); + assert.deepEqual(refusals.read(0), [{ + ordinal: 1, + refusal: { + at_tick: 7, + principal: "principal-red", + reason: "internal_error", + }, + }]); + assert.equal("field_path" in refusals.read(0)[0]!.refusal, false); +}); + +test("bounded refusal retention surfaces every overwritten ordinal", () => { + const refusals = journal(2); + refusals.refuse("principal-red", "request_malformed"); + refusals.refuse("principal-red", "affordance_not_granted"); + refusals.refuse("principal-red", "target_not_granted"); + + assert.throws( + () => refusals.read(0), + /world action refusal evidence overflow through ordinal 1/u, + ); + assert.deepEqual( + refusals.read(1).map(({ ordinal, refusal }) => [ordinal, refusal.reason]), + [[2, "affordance_not_granted"], [3, "target_not_granted"]], + ); + refusals.acknowledge(3); + assert.deepEqual(refusals.read(3), []); +}); + +test("refusal recording falls back to the last real host tick without throwing", () => { + let mode: "valid" | "invalid" | "throw" = "valid"; + const refusals = createWorldActionRefusalJournal({ + principals: ["principal-red"], + readTick: () => { + if (mode === "throw") throw new Error("tick unavailable"); + return mode === "invalid" ? Number.NaN : 11; + }, + }); + mode = "invalid"; + assert.doesNotThrow(() => + refusals.refuse("principal-red", "affordance_not_granted")); + mode = "throw"; + assert.doesNotThrow(() => + refusals.refuse("principal-red", "target_not_granted")); + assert.deepEqual( + refusals.read(0).map(({ refusal }) => refusal.at_tick), + [11, 11], + ); +}); + +test("runtime act returns and retains refusal after checked-session rollback closure", () => { + const fixture = runtimeFixtureWithHooks({ + failRestore: () => true, + available: () => true, + lower: (input) => ({ + force: (input as { input: { force: number } }).input.force, + }), + observe: (input, state) => { + state.value = Number(state.value) + 1; + return { + channels: (input as { sense_addresses: readonly string[] }) + .sense_addresses.map((senseAddress) => ({ + components: { x: 1 }, + sense_address: senseAddress, + subject_address: "object:red", + unit: "meters", + })), + }; + }, + }); + const runtime = fixture.runtime; + assert.ok(runtime); + const port = readWorldRuntimeActionRefusalJournalInspection(runtime); + assert.ok(port); + let receipt: ReturnType | undefined; + assert.doesNotThrow(() => { + receipt = runtime.act( + { principal: "principal-red", decisionToken: fixture.red.token }, + runtimeActEnvelope("rollback-closed-refusal", { + affordance: "world://pitch/affordance/kick", + target: "world://pitch/entity/ball", + input: { force: 0.5 }, + }), + ); + }); + assert.deepEqual(receipt, { + disposition: "rejected_at_ingress", + code: "world_action_denied", + reason: "world_surface_failed", + }); + assert.deepEqual(port.read(0), [{ + ordinal: 1, + refusal: { + at_tick: 0, + principal: "principal-red", + reason: "world_surface_failed", + }, + }]); +}); + +test("runtime inspection is exact-runtime, drain-only, and omits unknown principals", () => { + const fixture = runtimeFixtureWithHooks({}); + const runtime = fixture.runtime; + assert.ok(runtime); + const port = readWorldRuntimeActionRefusalJournalInspection(runtime); + assert.ok(port); + assert.equal(readWorldRuntimeActionRefusalJournalInspection({ ...runtime }), undefined); + assert.deepEqual(Object.keys(port), ["acknowledge", "read"]); + assert.equal("refuse" in port, false); + + const receipt = runtime.act( + { principal: "uncomposed-bearer", decisionToken: fixture.red.token }, + runtimeActEnvelope("unknown-principal", { + affordance: "world://pitch/affordance/kick", + target: "world://pitch/entity/ball", + input: { force: 1 }, + }), + ); + assert.equal(receipt.disposition, "rejected_at_ingress"); + assert.deepEqual(port.read(0), [{ + ordinal: 1, + refusal: { at_tick: 0, reason: "principal_unknown" }, + }]); + assert.equal("principal" in port.read(0)[0]!.refusal, false); +}); diff --git a/src/world/actionRefusalJournal.ts b/src/world/actionRefusalJournal.ts new file mode 100644 index 0000000..bcd230d --- /dev/null +++ b/src/world/actionRefusalJournal.ts @@ -0,0 +1,171 @@ +import { DYNAMICS_ACTION_RETENTION_LIMITS } from "../dynamics/limits.js"; +import { + isWorldActIngressRejectionFieldPath, + isWorldActIngressRejectionReason, + type WorldActIngressRejectionReason, +} from "../world-surface/index.js"; +import type { WorldActIngressRejection } from "./actTypes.js"; + +export interface WorldActionRefusal { + readonly at_tick: number; + readonly principal?: string; + readonly reason: WorldActIngressRejectionReason; + readonly field_path?: string; +} + +export interface WorldActionRefusalEvidence { + readonly ordinal: number; + readonly refusal: WorldActionRefusal; +} + +export interface WorldActionRefusalReadPort { + read(afterOrdinal: number): readonly WorldActionRefusalEvidence[]; + acknowledge(ordinal: number): void; +} + +export interface WorldActionRefusalJournal extends WorldActionRefusalReadPort { + refuse( + principal: unknown, + reason: unknown, + fieldPath?: unknown, + ): WorldActIngressRejection; +} + +export interface CreateWorldActionRefusalJournalOptions { + readonly capacity?: number; + readonly principals: readonly string[]; + readonly readTick: () => number; +} + +const issued = new WeakSet(); +const frozen = (value: Value): Value => Object.freeze(value); +const ordinal = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const cloneRefusal = (value: WorldActionRefusal): WorldActionRefusal => frozen({ + at_tick: value.at_tick, + ...(value.principal === undefined ? {} : { principal: value.principal }), + reason: value.reason, + ...(value.field_path === undefined ? {} : { field_path: value.field_path }), +}); +const cloneEvidence = ( + value: WorldActionRefusalEvidence, +): WorldActionRefusalEvidence => frozen({ + ordinal: value.ordinal, + refusal: cloneRefusal(value.refusal), +}); + +export const readWorldActionRefusalJournal = ( + value: unknown, +): WorldActionRefusalJournal | undefined => + value !== null && typeof value === "object" && issued.has(value) + ? value as WorldActionRefusalJournal + : undefined; + +/** + * Host-only refusal evidence. Retention is bounded; if undrained entries are + * overwritten, read() throws for every cursor behind the lost ordinal instead + * of returning a silently incomplete stream. + */ +export const createWorldActionRefusalJournal = ( + options: CreateWorldActionRefusalJournalOptions, +): WorldActionRefusalJournal => { + const capacity = options.capacity ?? DYNAMICS_ACTION_RETENTION_LIMITS.records; + if (!Number.isSafeInteger(capacity) || capacity < 1) { + throw new Error("world action refusal journal capacity must be positive"); + } + const initialTick = options.readTick(); + if (!ordinal(initialTick)) { + throw new Error("world action refusal journal requires an initial host tick"); + } + const principals = new Set(options.principals); + let entries: WorldActionRefusalEvidence[] = []; + let currentOrdinal = 0; + let lastTick = initialTick; + let lostThrough = 0; + let unordinaledLoss = false; + + const journal: WorldActionRefusalJournal = frozen({ + acknowledge: (throughOrdinal: number): void => { + if (!ordinal(throughOrdinal) || throughOrdinal > currentOrdinal) { + throw new Error( + "world action refusal acknowledgment is outside the issued range", + ); + } + entries = entries.filter((entry) => entry.ordinal > throughOrdinal); + }, + read: (afterOrdinal: number): readonly WorldActionRefusalEvidence[] => { + if (!ordinal(afterOrdinal)) { + throw new Error( + "world action refusal ordinal must be a non-negative safe integer", + ); + } + if (unordinaledLoss) { + throw new Error( + `world action refusal evidence loss after ordinal ${currentOrdinal}`, + ); + } + if (afterOrdinal < lostThrough) { + throw new Error( + `world action refusal evidence overflow through ordinal ${lostThrough}`, + ); + } + return frozen(entries + .filter((entry) => entry.ordinal > afterOrdinal) + .map(cloneEvidence)); + }, + refuse: ( + principal: unknown, + reason: unknown, + fieldPath?: unknown, + ): WorldActIngressRejection => { + const bounded = isWorldActIngressRejectionReason(reason) + && (fieldPath === undefined + || isWorldActIngressRejectionFieldPath(fieldPath)); + const safeReason: WorldActIngressRejectionReason = bounded + ? reason + : "internal_error"; + const safeFieldPath = bounded ? fieldPath as string | undefined : undefined; + const receipt: WorldActIngressRejection = frozen({ + disposition: "rejected_at_ingress", + code: "world_action_denied", + reason: safeReason, + ...(safeFieldPath === undefined ? {} : { field_path: safeFieldPath }), + }); + try { + const observedTick = options.readTick(); + if (ordinal(observedTick)) lastTick = observedTick; + } catch { /* retain the last real host tick */ } + if (currentOrdinal >= Number.MAX_SAFE_INTEGER) { + unordinaledLoss = true; + return receipt; + } + const attemptedOrdinal = currentOrdinal + 1; + try { + const refusal = cloneRefusal({ + at_tick: lastTick, + ...(typeof principal === "string" && principals.has(principal) + ? { principal } + : {}), + reason: safeReason, + ...(safeFieldPath === undefined ? {} : { field_path: safeFieldPath }), + }); + currentOrdinal = attemptedOrdinal; + entries.push(frozen({ ordinal: attemptedOrdinal, refusal })); + if (entries.length > capacity) { + const lost = entries.shift(); + if (lost === undefined) { + lostThrough = Math.max(lostThrough, attemptedOrdinal); + } else { + lostThrough = Math.max(lostThrough, lost.ordinal); + } + } + } catch { + currentOrdinal = Math.max(currentOrdinal, attemptedOrdinal); + lostThrough = Math.max(lostThrough, attemptedOrdinal); + } + return receipt; + }, + }); + issued.add(journal); + return journal; +}; diff --git a/src/world/actionResult.ts b/src/world/actionResult.ts new file mode 100644 index 0000000..a0e3462 --- /dev/null +++ b/src/world/actionResult.ts @@ -0,0 +1,83 @@ +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { copyHostileJson, type HostileJson } from "./hostileJson.js"; + +export const WORLD_ACTION_RESULT_VERSION = "simfile.world-action-result.v1" as const; + +export interface WorldActionResultIdentity { + readonly run_id: string; readonly world_id: string; readonly world_instance_id: string; + readonly manifest_digest: string; readonly state_version: number; +} +export interface WorldActionResultApplied { + readonly version: typeof WORLD_ACTION_RESULT_VERSION; readonly result_id: string; readonly receipt_id: string; + readonly decision_id: string; readonly actor: string; readonly action_sequence: number; readonly apply_tick: number; + readonly status: "applied"; readonly caused_effect_ids: readonly string[]; readonly identity: WorldActionResultIdentity; +} +export interface WorldActionResultRejected { + readonly version: typeof WORLD_ACTION_RESULT_VERSION; readonly result_id: string; readonly receipt_id: string; + readonly decision_id: string; readonly actor: string; readonly action_sequence: number; readonly apply_tick: number; + readonly status: "rejected_at_mechanics"; readonly rejection_code: string; readonly identity: WorldActionResultIdentity; +} +export type WorldActionResult = WorldActionResultApplied | WorldActionResultRejected; + +const TEXT = DYNAMICS_LIMITS.identifier_code_units; +const SHA256 = /^sha256:[a-f0-9]{64}$/u; +const ADDRESS = /^world:\/\/(?:[a-z][a-z0-9]*(?:-[a-z0-9]+)*\/)+entity\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +const DECISION = /^decision-[0-9]{12}$/u; +const CODE = /^[a-z][a-z0-9_]{0,63}$/u; +const frozen = (value: T): T => Object.freeze(value); +const safe = (value: unknown, minimum: number): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= minimum; +const text = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= TEXT && value === value.trim(); +const suffix = (value: unknown, prefix: string): number | undefined => { + if (typeof value !== "string" || value.length > TEXT || !value.startsWith(prefix)) return undefined; + const digits = value.slice(prefix.length); + if (!/^[1-9][0-9]*$/u.test(digits)) return undefined; + const number = Number(digits); return Number.isSafeInteger(number) ? number : undefined; +}; +const decisionSequence = (value: unknown): number | undefined => { + if (typeof value !== "string" || !DECISION.test(value)) return undefined; + const number = Number(value.slice("decision-".length)); return Number.isSafeInteger(number) && number >= 1 ? number : undefined; +}; +const actor = (value: unknown, worldId: string): value is string => typeof value === "string" + && ADDRESS.test(value) && value.startsWith(`world://${worldId}/entity/`); +const fields = (value: HostileJson, expected: readonly string[]): Readonly> | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value)) return undefined; + const keys = Object.keys(value); + return keys.length === expected.length && keys.every((key) => expected.includes(key)) && expected.every((key) => Object.hasOwn(value, key)) + ? value as Readonly> : undefined; +}; + +export const parseWorldActionResultIdentity = (value: unknown): WorldActionResultIdentity | undefined => { + let copy: HostileJson; try { copy = copyHostileJson(value); } catch { return undefined; } + const source = fields(copy, ["run_id", "world_id", "world_instance_id", "manifest_digest", "state_version"]); + if (source === undefined || !text(source.run_id) || !text(source.world_id) || !text(source.world_instance_id) + || typeof source.manifest_digest !== "string" || !SHA256.test(source.manifest_digest) || !safe(source.state_version, 0)) return undefined; + return frozen({ run_id: source.run_id, world_id: source.world_id, world_instance_id: source.world_instance_id, + manifest_digest: source.manifest_digest, state_version: source.state_version }); +}; +const effects = (value: HostileJson): readonly string[] | undefined => { + if (!Array.isArray(value) || value.length > DYNAMICS_LIMITS.events_per_tick) return undefined; + const output: string[] = []; + for (const entry of value) { if (suffix(entry, "world-effect-") === undefined) return undefined; output.push(entry as string); } + return new Set(output).size === output.length ? frozen(output) : undefined; +}; + +/** Parses one complete, frozen terminal result; declared rejection ownership belongs to ledger admission. */ +export const parseWorldActionResult = (value: unknown): WorldActionResult | undefined => { + let copy: HostileJson; try { copy = copyHostileJson(value); } catch { return undefined; } + if (copy === null || typeof copy !== "object" || Array.isArray(copy)) return undefined; + const status = (copy as Readonly>).status; + const source = fields(copy, status === "applied" + ? ["version", "result_id", "receipt_id", "decision_id", "actor", "action_sequence", "apply_tick", "status", "caused_effect_ids", "identity"] + : status === "rejected_at_mechanics" + ? ["version", "result_id", "receipt_id", "decision_id", "actor", "action_sequence", "apply_tick", "status", "rejection_code", "identity"] : []); + if (source === undefined || source.version !== WORLD_ACTION_RESULT_VERSION || suffix(source.result_id, "world-result-") === undefined + || suffix(source.receipt_id, "world-act-") === undefined || typeof source.decision_id !== "string" || !DECISION.test(source.decision_id) + || decisionSequence(source.decision_id) === undefined || !safe(source.action_sequence, 1) || !safe(source.apply_tick, 0) + || suffix(source.receipt_id, "world-act-") !== source.action_sequence) return undefined; + const checkedIdentity = parseWorldActionResultIdentity(source.identity); + if (checkedIdentity === undefined || !actor(source.actor, checkedIdentity.world_id)) return undefined; + const base = { version: WORLD_ACTION_RESULT_VERSION, result_id: source.result_id as string, receipt_id: source.receipt_id as string, decision_id: source.decision_id, + actor: source.actor, action_sequence: source.action_sequence, apply_tick: source.apply_tick, identity: checkedIdentity } as const; + if (status === "applied") { const checked = effects(source.caused_effect_ids); return checked === undefined ? undefined : frozen({ ...base, status: "applied", caused_effect_ids: checked }); } + return typeof source.rejection_code === "string" && CODE.test(source.rejection_code) ? frozen({ ...base, status: "rejected_at_mechanics", rejection_code: source.rejection_code }) : undefined; +}; diff --git a/src/world/actionResultLedger.test.ts b/src/world/actionResultLedger.test.ts new file mode 100644 index 0000000..617efff --- /dev/null +++ b/src/world/actionResultLedger.test.ts @@ -0,0 +1,121 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { WORLD_ACTION_RESULT_VERSION, parseWorldActionResult } from "./actionResult.js"; +import { createWorldActionResultLedger, parseWorldActionResultPageRequest, readWorldActionResultLedger, WORLD_ACTION_RESULT_CURSOR_VERSION, WORLD_ACTION_RESULT_PAGE_REQUEST_VERSION } from "./actionResultLedger.js"; +import { createWorldReadLedger } from "./ledger.js"; +import * as world from "./index.js"; + +const digest = (letter: string) => `sha256:${letter.repeat(64)}`; +const red = Object.freeze({ principal: "red", actor: "world://world/entity/red", run_id: "run", world_id: "world", world_instance_id: "instance", manifest_digest: digest("a") }); +const blue = Object.freeze({ principal: "blue", actor: "world://world/entity/blue", run_id: "run", world_id: "world", world_instance_id: "instance", manifest_digest: digest("b") }); +const result = (principal: typeof red | typeof blue, action = 1, decision = 17) => ({ version: WORLD_ACTION_RESULT_VERSION, result_id: `world-result-${action + 100}`, receipt_id: `world-act-${action}`, decision_id: `decision-${String(decision).padStart(12, "0")}`, actor: principal.actor, action_sequence: action, apply_tick: 3, status: "applied" as const, caused_effect_ids: [`world-effect-${action}`], identity: { run_id: principal.run_id, world_id: principal.world_id, world_instance_id: principal.world_instance_id, manifest_digest: principal.manifest_digest, state_version: 3 } }); +const rejected = (principal: typeof red | typeof blue, action: number, decision: number, rejection_code = "world_action_rejected") => { const { caused_effect_ids: _effects, ...base } = result(principal, action, decision); return { ...base, status: "rejected_at_mechanics" as const, rejection_code }; }; +const authority = (ledger: ReturnType) => readWorldActionResultLedger(ledger)!; +const reserve = (ledger: ReturnType, bindings: readonly unknown[] = [red, blue]) => authority(ledger).reserve({ bindings }); +const request = (overrides: Record = {}) => ({ version: WORLD_ACTION_RESULT_PAGE_REQUEST_VERSION, ...overrides }); + +test("parses frozen canonical results with independent decision issuance and bounded effects", () => { + const parsed = parseWorldActionResult(result(red, 1, 17))!; + assert.ok(Object.isFrozen(parsed)); assert.equal(parsed.decision_id, "decision-000000000017"); + if (parsed.status !== "applied") throw new Error("expected applied"); + assert.ok(Object.isFrozen(parsed.caused_effect_ids)); assert.throws(() => (parsed.caused_effect_ids as string[]).push("x"), TypeError); + const ceiling = { ...result(red), caused_effect_ids: Array.from({ length: DYNAMICS_LIMITS.events_per_tick }, (_, index) => `world-effect-${index + 1}`) }; + assert.ok(parseWorldActionResult(ceiling)); + assert.equal(parseWorldActionResult({ ...ceiling, caused_effect_ids: [...ceiling.caused_effect_ids, "world-effect-999"] }), undefined); + for (const value of [{ ...result(red), actor: "red" }, { ...result(red), actor: "world://other/entity/red" }, { ...result(red), receipt_id: "world-act-9" }, { ...result(red), decision_id: "decision-x" }, { ...result(red), decision_id: "decision-000000000000" }, { ...result(red), result_id: "world-result-9007199254740992" }, { ...result(red), caused_effect_ids: ["world-effect-1", "world-effect-1"] }, { ...result(red), rejection_code: "world_action_rejected" }, { ...rejected(red, 1, 17), caused_effect_ids: [] }]) assert.equal(parseWorldActionResult(value), undefined); +}); + +test("parsers reject hostile fields without coercion or foreign evaluation", () => { + let called = 0; const accessor = { ...result(red) } as Record; + Object.defineProperty(accessor, "actor", { enumerable: true, get: () => { called += 1; return red.actor; } }); + assert.equal(parseWorldActionResult(accessor), undefined); assert.equal(called, 0); + const ledger = createWorldActionResultLedger(); reserve(ledger); const writer = authority(ledger); writer.append({ principal: "red", result: result(red) }); const cursor = ledger.read("red", request()).next_result_after!; + const hostile = (field: "issuer" | "proof") => ({ ...cursor, [field]: Symbol(field) }); + for (const cursorValue of [hostile("issuer"), hostile("proof"), { ...cursor, issuer: {} }, { ...cursor, proof: {} }, new Proxy(cursor, { getPrototypeOf: () => { called += 1; throw new Error("trap"); } })]) { + assert.equal(parseWorldActionResultPageRequest(request({ result_after: cursorValue })), undefined); + assert.throws(() => ledger.read("red", request({ result_after: cursorValue })), /World runtime request denied/u); + } + assert.equal(called, 0); +}); + +test("keeps the public request authority-free and pages only issued result cursors", () => { + const ledger = createWorldActionResultLedger(); reserve(ledger); const writer = authority(ledger); writer.append({ principal: "red", result: result(red) }); + const page = ledger.read("red", request()); assert.equal(page.results.length, 1); assert.equal(page.next_result_after?.version, WORLD_ACTION_RESULT_CURSOR_VERSION); + for (const input of [request({ identity: {} }), request({ principal: "red" }), request({ state_version: 3 }), request({ limit: 101 }), request({ result_after: 1 })]) assert.equal(parseWorldActionResultPageRequest(input), undefined); + assert.equal("records" in page, false); assert.equal("next_cursor" in page, false); assert.equal("createWorldActionResultLedger" in world, false); assert.equal("readWorldActionResultLedger" in world, false); +}); + +test("binds each append and cursor to its exact host reservation", () => { + const source = createWorldActionResultLedger(); const other = createWorldActionResultLedger(); reserve(source); reserve(other); const writer = authority(source); + writer.append({ principal: "red", result: result(red, 1, 17) }); writer.append({ principal: "blue", result: result(blue, 2, 18) }); + const cursor = JSON.parse(JSON.stringify(source.read("red", request()).next_result_after)); + assert.throws(() => other.read("red", request({ result_after: cursor })), /denied/u); + assert.throws(() => source.read("blue", request({ result_after: cursor })), /denied/u); + assert.throws(() => writer.append({ principal: "blue", result: result(red, 3, 19) }), /construction/u); + assert.throws(() => writer.append({ principal: "red", result: { ...result(red, 3, 19), actor: blue.actor } }), /construction/u); + assert.throws(() => writer.append({ principal: "red", result: { ...result(red, 3, 19), actor: "world://other/entity/red" } }), /construction/u); + assert.throws(() => writer.append({ principal: "red", result: { ...result(red, 3, 19), identity: { ...result(red, 3, 19).identity, manifest_digest: blue.manifest_digest } } }), /construction/u); +}); + +test("rejects cross-world actors at every public and host boundary", () => { + const foreign = { ...red, actor: "world://other/entity/red" }; + assert.equal(parseWorldActionResult(result(foreign as typeof red)), undefined); + const ledger = createWorldActionResultLedger(); const writer = authority(ledger); + assert.throws(() => reserve(ledger, [foreign]), /construction/u); + reserve(ledger); writer.append({ principal: "red", result: result(red, 1, 17) }); + const cursor = ledger.read("red", request()).next_result_after!; + assert.throws(() => ledger.read("red", request({ result_after: { ...cursor, world_id: "other" } })), /denied/u); +}); + +test("uses principal paging sequences and fails only after an unseen same-principal eviction", () => { + const ledger = createWorldActionResultLedger({ maxEntriesPerPrincipal: 1 }); reserve(ledger); const writer = authority(ledger); + writer.append({ principal: "red", result: result(red, 1, 17) }); const seenRedOne = ledger.read("red", request()).next_result_after!; + writer.append({ principal: "blue", result: result(blue, 2, 18) }); writer.append({ principal: "red", result: result(red, 3, 19) }); + const afterSeen = ledger.read("red", request({ result_after: seenRedOne })); assert.equal(afterSeen.results[0]?.receipt_id, "world-act-3"); + writer.append({ principal: "red", result: result(red, 4, 20) }); assert.throws(() => ledger.read("red", request({ result_after: seenRedOne })), /denied/u); + const empty = ledger.read("blue", request({ result_after: ledger.read("blue", request()).next_result_after })); + assert.equal(empty.results.length, 0); assert.deepEqual(empty.next_result_after, ledger.read("blue", request({ result_after: empty.next_result_after })).next_result_after); +}); + +test("enforces globally bounded exact-once terminal admission and declared rejection codes", () => { + const ledger = createWorldActionResultLedger(); reserve(ledger); const writer = authority(ledger); writer.append({ principal: "red", result: rejected(red, 1, 17, "declared"), declared_rejection_codes: ["declared"] }); + for (const duplicate of [{ ...result(blue, 2, 18), result_id: "world-result-101" }, { ...result(blue, 2, 18), receipt_id: "world-act-1" }, { ...result(blue, 2, 17) }]) assert.throws(() => writer.append({ principal: "blue", result: duplicate }), /construction/u); + assert.throws(() => writer.append({ principal: "blue", result: rejected(blue, 2, 18, "undeclared") }), /construction/u); + const audit = createWorldReadLedger(); assert.throws(() => audit.read("red", { after: ledger.read("red", request()).next_result_after as unknown as number })); +}); + +test("freezes cloned values and rejects hostile result, binding, request, and option sources", () => { + const parsed = parseWorldActionResult(result(red))!; + assert.ok(Object.isFrozen(parsed.identity)); assert.throws(() => (parsed.identity as { state_version: number }).state_version = 9, TypeError); + const mutable = result(red, 1, 17); const ledger = createWorldActionResultLedger(); reserve(ledger); authority(ledger).append({ principal: "red", result: mutable }); + mutable.identity.state_version = 99; mutable.caused_effect_ids[0] = "world-effect-99"; + const page = ledger.read("red", request()); assert.ok(Object.isFrozen(page)); assert.ok(Object.isFrozen(page.results)); assert.ok(Object.isFrozen(page.results[0])); + assert.equal(page.results[0]!.identity.state_version, 3); assert.equal((page.results[0] as typeof parsed & { caused_effect_ids: readonly string[] }).caused_effect_ids[0], "world-effect-1"); + const cursor = page.next_result_after!; const parsedRequest = parseWorldActionResultPageRequest(request({ result_after: cursor }))!; + assert.ok(Object.isFrozen(parsedRequest)); assert.ok(Object.isFrozen(parsedRequest.result_after)); assert.ok(Object.isFrozen(cursor)); + const cycle: Record = { ...result(red) }; cycle.self = cycle; + const sparse = ["world-effect-1"] as string[]; sparse.length = 2; + const thenable = { ...result(red), then: () => undefined }; const prototypeThenable = Object.create({ then: () => undefined }); Object.assign(prototypeThenable, result(red)); + const inherited = Object.create(result(red)); const accessor = { ...result(red) }; Object.defineProperty(accessor, "actor", { enumerable: true, get: () => red.actor }); + const exotic = ["world-effect-1"]; Object.setPrototypeOf(exotic, null); + for (const hostile of [cycle, { ...result(red), caused_effect_ids: sparse }, { ...result(red), caused_effect_ids: exotic }, { ...result(red), actor: Symbol("actor") }, thenable, prototypeThenable, inherited, accessor, new Proxy(result(red), {})]) assert.equal(parseWorldActionResult(hostile), undefined); + const binding = { ...red, actor: "world://other/entity/red" }; const hostileOptions = new Proxy({}, {}); + assert.throws(() => createWorldActionResultLedger(hostileOptions), /construction/u); assert.throws(() => authority(createWorldActionResultLedger()).reserve({ bindings: [binding] }), /construction/u); + const bindingProxy = new Proxy(red, {}); assert.throws(() => authority(createWorldActionResultLedger()).reserve({ bindings: [bindingProxy] }), /construction/u); + for (const hostile of [{ ...request(), extra: true }, Object.create(request()), { ...request(), result_after: [] }, { ...request(), result_after: Symbol("cursor") }, new Proxy(request(), {})]) assert.equal(parseWorldActionResultPageRequest(hostile), undefined); +}); + +test("leaves failed reservation, append, and read operations atomic and retryable", () => { + const ledger = createWorldActionResultLedger({ maxEntriesPerPrincipal: 1 }); const writer = authority(ledger); + assert.throws(() => writer.reserve({ bindings: [red, red] }), /construction/u); reserve(ledger, [red]); + assert.throws(() => writer.append({ principal: "red", result: { ...result(red), actor: "world://other/entity/red" } }), /construction/u); + writer.append({ principal: "red", result: result(red, 1, 17) }); const before = ledger.read("red", request()); + assert.throws(() => ledger.read("red", request({ result_after: { ...before.next_result_after!, proof: "0".repeat(64) } })), /denied/u); + assert.deepEqual(ledger.read("red", request()), before); assert.equal(ledger.read("red", request({ result_after: before.next_result_after })).results.length, 0); +}); + +test("keeps all host construction and authority seams out of the public barrel", () => { + for (const name of ["createWorldActionResultLedger", "readWorldActionResultLedger", "parseWorldActionResultPageRequest", "WORLD_ACTION_RESULT_CURSOR_VERSION", "WORLD_ACTION_RESULT_PAGE_REQUEST_VERSION", "reservation", "snapshot", "restore", "journal", "requestLedger", "clockAuthority", "inspection"]) assert.equal(name in world, false); +}); diff --git a/src/world/actionResultLedger.ts b/src/world/actionResultLedger.ts new file mode 100644 index 0000000..3aa3dc0 --- /dev/null +++ b/src/world/actionResultLedger.ts @@ -0,0 +1,149 @@ +import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; +import { types } from "node:util"; + +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { parseWorldActionResult, type WorldActionResult } from "./actionResult.js"; +import { WorldRuntimeError } from "./ledger.js"; + +export const WORLD_ACTION_RESULT_PAGE_REQUEST_VERSION = "simfile.world-action-result-page-request.v1" as const; +export const WORLD_ACTION_RESULT_CURSOR_VERSION = "simfile.world-action-result-cursor.v1" as const; +export const WORLD_ACTION_RESULT_LEDGER_SNAPSHOT_VERSION = "simfile.world-action-result-ledger.v1" as const; +export interface WorldActionResultCursor { readonly version: typeof WORLD_ACTION_RESULT_CURSOR_VERSION; readonly issuer: string; readonly principal: string; readonly run_id: string; readonly world_id: string; readonly world_instance_id: string; readonly manifest_digest: string; readonly after: number; readonly proof: string; } +export interface WorldActionResultPageRequest { readonly version: typeof WORLD_ACTION_RESULT_PAGE_REQUEST_VERSION; readonly limit: number; readonly result_after?: WorldActionResultCursor; } +export interface WorldActionResultPage { readonly results: readonly WorldActionResult[]; readonly next_result_after?: WorldActionResultCursor; } +export interface WorldActionResultLedger { read(principal: string, request: unknown): WorldActionResultPage; } +export interface WorldActionResultLedgerOptions { readonly maxEntriesPerPrincipal?: number; readonly maxPrincipals?: number; } +export interface WorldActionResultBatchReservation { resultId(index: number): string; effectId(index: number): string; publish(results: readonly WorldActionResult[]): void; abort(): void; } +export interface WorldActionResultLedgerAuthority { + append(input: unknown): void; read(principal: string, request: unknown): WorldActionResultPage; reserve(input: unknown): void; + reserveBatch(input: unknown): WorldActionResultBatchReservation; exportState(): LedgerSnapshotState; importState(state: LedgerSnapshotState): void; + hasLiveReservation(): boolean; +} +export type LedgerBinding = Readonly<{ principal: string; actor: string; run_id: string; world_id: string; world_instance_id: string; manifest_digest: string }>; +export type LedgerEntry = Readonly<{ page: number; result: WorldActionResult }>; +export interface LedgerSnapshotState { + readonly version: typeof WORLD_ACTION_RESULT_LEDGER_SNAPSHOT_VERSION; readonly max_entries: number; readonly max_principals: number; + readonly issuer: string; readonly secret: string; readonly bindings: readonly LedgerBinding[]; readonly entries: readonly Readonly<{ principal: string; values: readonly LedgerEntry[] }> []; + readonly pages: readonly Readonly<[string, number]>[]; readonly evicted: readonly Readonly<[string, number]>[]; + readonly result_ids: readonly string[]; readonly receipt_ids: readonly string[]; readonly decision_ids: readonly string[]; readonly action_sequences: readonly number[]; + readonly effect_watermarks: readonly number[]; + readonly admitted: number; readonly previous_action: number; readonly next_result: number; readonly next_effect: number; +} + +const DEFAULT_LIMIT = 50, MAX_LIMIT = 100, DEFAULT_ENTRIES = 256, DEFAULT_PRINCIPALS = 256; +const MAX_ENTRIES = DYNAMICS_LIMITS.retained_action_records, MAX_PRINCIPALS = 4_096, TEXT = DYNAMICS_LIMITS.identifier_code_units; +/** A successor must remain representable by the standalone snapshot parser. */ +const MAX_NEXT_COUNTER = Number.MAX_SAFE_INTEGER - 1; +const SHA256 = /^sha256:[a-f0-9]{64}$/u, ADDRESS = /^world:\/\/(?:[a-z][a-z0-9]*(?:-[a-z0-9]+)*\/)+entity\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +const RESULT = /^world-result-([1-9][0-9]*)$/u, EFFECT = /^world-effect-([1-9][0-9]*)$/u, ACT = /^world-act-[1-9][0-9]*$/u, DECISION = /^decision-[0-9]{12}$/u, CODE = /^[a-z][a-z0-9_]{0,63}$/u; +const issued = new WeakMap(); +const frozen = (value: T): T => Object.freeze(value); +const safe = (value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max; +const text = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= TEXT && value === value.trim(); +const invalid = (): never => { throw new WorldRuntimeError("world_runtime_invalid_composition"); }; +const denied = (): never => { throw new WorldRuntimeError("world_runtime_denied"); }; +const own = (value: unknown, allowed: readonly string[], exact = false): Record | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) return undefined; + const keys = Reflect.ownKeys(value); if ((exact && keys.length !== allowed.length) || keys.some((key) => typeof key !== "string" || !allowed.includes(key))) return undefined; + const out: Record = Object.create(null); + for (const key of keys) { const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor?.enumerable || !("value" in descriptor)) return undefined; out[key as string] = descriptor.value; } + return out; +}; +const array = (value: unknown, max: number, min = 0): readonly unknown[] | undefined => { + if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const length = Object.getOwnPropertyDescriptor(value, "length")?.value; + if (!safe(length, min, max) || Reflect.ownKeys(value).length !== length + 1) return undefined; + const out: unknown[] = []; + for (let index = 0; index < length; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); if (!descriptor?.enumerable || !("value" in descriptor)) return undefined; out.push(descriptor.value); } + return out; +}; +const numberSuffix = (value: string, expression: RegExp): number | undefined => { const match = expression.exec(value); if (!match) return undefined; const number = Number(match[1]); return safe(number, 1) ? number : undefined; }; +const clone = (value: WorldActionResult): WorldActionResult => parseWorldActionResult(value)!; +const binding = (value: unknown): LedgerBinding | undefined => { + const source = own(value, ["principal", "actor", "run_id", "world_id", "world_instance_id", "manifest_digest"], true); + if (source === undefined || !text(source.principal) || typeof source.actor !== "string" || !ADDRESS.test(source.actor) || !text(source.run_id) || !text(source.world_id) || !text(source.world_instance_id) || typeof source.manifest_digest !== "string" || !SHA256.test(source.manifest_digest) || !source.actor.startsWith(`world://${source.world_id}/entity/`)) return undefined; + return frozen({ principal: source.principal, actor: source.actor, run_id: source.run_id, world_id: source.world_id, world_instance_id: source.world_instance_id, manifest_digest: source.manifest_digest }); +}; +const codes = (value: unknown): readonly string[] | undefined => { + if (value === undefined) return frozen([]); const values = array(value, MAX_ENTRIES); + return values !== undefined && values.every((item) => typeof item === "string" && CODE.test(item)) && new Set(values).size === values.length ? frozen([...values] as string[]) : undefined; +}; +const hmac = (secret: Buffer, fields: readonly string[]): string => createHmac("sha256", secret).update(fields.join("\u0000")).digest("hex"); +const cursor = (value: unknown): WorldActionResultCursor | undefined => { + const source = own(value, ["version", "issuer", "principal", "run_id", "world_id", "world_instance_id", "manifest_digest", "after", "proof"], true); + if (source === undefined || source.version !== WORLD_ACTION_RESULT_CURSOR_VERSION || typeof source.issuer !== "string" || !/^[a-f0-9]{32}$/u.test(source.issuer) || !text(source.principal) || !text(source.run_id) || !text(source.world_id) || !text(source.world_instance_id) || typeof source.manifest_digest !== "string" || !SHA256.test(source.manifest_digest) || !safe(source.after, 0) || typeof source.proof !== "string" || !/^[a-f0-9]{64}$/u.test(source.proof)) return undefined; + return frozen({ version: WORLD_ACTION_RESULT_CURSOR_VERSION, issuer: source.issuer, principal: source.principal, run_id: source.run_id, world_id: source.world_id, world_instance_id: source.world_instance_id, manifest_digest: source.manifest_digest, after: source.after, proof: source.proof }); +}; + +export const parseWorldActionResultPageRequest = (input: unknown): WorldActionResultPageRequest | undefined => { + const source = own(input, ["version", "limit", "result_after"]); const after = source?.result_after === undefined ? undefined : cursor(source.result_after); + if (source === undefined || source.version !== WORLD_ACTION_RESULT_PAGE_REQUEST_VERSION || !safe(source.limit ?? DEFAULT_LIMIT, 1, MAX_LIMIT) || (source.result_after !== undefined && after === undefined)) return undefined; + return frozen({ version: WORLD_ACTION_RESULT_PAGE_REQUEST_VERSION, limit: source.limit as number ?? DEFAULT_LIMIT, ...(after === undefined ? {} : { result_after: after }) }); +}; +export const readWorldActionResultLedger = (value: unknown): WorldActionResultLedgerAuthority | undefined => value !== null && typeof value === "object" ? issued.get(value) : undefined; + +export const createWorldActionResultLedger = (options: unknown = {}): WorldActionResultLedger => { + const option = own(options, ["maxEntriesPerPrincipal", "maxPrincipals"]); const maximum = option?.maxEntriesPerPrincipal ?? DEFAULT_ENTRIES, capacity = option?.maxPrincipals ?? DEFAULT_PRINCIPALS; + if (option === undefined || !safe(maximum, 1, MAX_ENTRIES) || !safe(capacity, 1, MAX_PRINCIPALS)) return invalid(); + let bindings = new Map(), entries = new Map(), pages = new Map(), evicted = new Map(); + let resultIds = new Set(), receiptIds = new Set(), decisionIds = new Set(), actionSequences = new Set(); + let effectWatermarks: number[] = []; + let issuer = randomBytes(16).toString("hex"), secret = randomBytes(32), admitted = 0, previousAction = 0, nextResult = 1, nextEffect = 1, restoreUsed = false; + let live: { readonly actions: readonly Readonly<{ principal: string; receipt_id: string; decision_id: string; action_sequence: number; codes: readonly string[] }>[]; readonly effectCapacity: number; readonly resultStart: number; readonly effectStart: number } | undefined; + const bound = (result: WorldActionResult, principal: string, declared: readonly string[]): boolean => { + const target = bindings.get(principal); + return target !== undefined && result.actor === target.actor && result.identity.run_id === target.run_id && result.identity.world_id === target.world_id && result.identity.world_instance_id === target.world_instance_id && result.identity.manifest_digest === target.manifest_digest && (result.status === "applied" || result.rejection_code === "world_action_rejected" || declared.includes(result.rejection_code)); + }; + const add = (principal: string, result: WorldActionResult, effectWatermark: number): void => { + const page = (pages.get(principal) ?? 0) + 1, retained = [...entries.get(principal)!, frozen({ page, result: clone(result) })]; + if (retained.length > maximum) evicted.set(principal, retained.splice(0, retained.length - maximum).at(-1)!.page); + entries.set(principal, retained); pages.set(principal, page); resultIds.add(result.result_id); receiptIds.add(result.receipt_id); decisionIds.add(result.decision_id); actionSequences.add(result.action_sequence); + admitted += 1; previousAction = result.action_sequence; effectWatermarks.push(effectWatermark); + }; + const makeCursor = (target: LedgerBinding, after: number): WorldActionResultCursor => frozen({ version: WORLD_ACTION_RESULT_CURSOR_VERSION, issuer, principal: target.principal, run_id: target.run_id, world_id: target.world_id, world_instance_id: target.world_instance_id, manifest_digest: target.manifest_digest, after, proof: hmac(secret, [issuer, target.principal, target.actor, target.run_id, target.world_id, target.world_instance_id, target.manifest_digest, String(after)]) }); + const parseCursor = (value: unknown, target: LedgerBinding): number | undefined => { const parsed = cursor(value); if (parsed === undefined || parsed.issuer !== issuer || parsed.principal !== target.principal || parsed.run_id !== target.run_id || parsed.world_id !== target.world_id || parsed.world_instance_id !== target.world_instance_id || parsed.manifest_digest !== target.manifest_digest) return undefined; const expected = hmac(secret, [issuer, target.principal, target.actor, target.run_id, target.world_id, target.world_instance_id, target.manifest_digest, String(parsed.after)]); return timingSafeEqual(Buffer.from(expected, "hex"), Buffer.from(parsed.proof, "hex")) ? parsed.after : undefined; }; + const reserve = (input: unknown): void => { + const values = own(input, ["bindings"], true); const parsed = values === undefined ? undefined : array(values.bindings, capacity, 1)?.map(binding); + if (parsed === undefined || parsed.some((item) => item === undefined) || bindings.size !== 0 || admitted !== 0 || new Set(parsed.map((item) => item!.principal)).size !== parsed.length) return invalid(); + for (const item of parsed as LedgerBinding[]) { bindings.set(item.principal, item); entries.set(item.principal, []); pages.set(item.principal, 0); evicted.set(item.principal, 0); } + }; + const append = (input: unknown): void => { + const source = own(input, ["principal", "result", "declared_rejection_codes"]), result = source === undefined ? undefined : parseWorldActionResult(source.result), declared = source === undefined ? undefined : codes(source.declared_rejection_codes); + if (live !== undefined || source === undefined || !text(source.principal) || result === undefined || declared === undefined || admitted >= MAX_ENTRIES || !bound(result, source.principal, declared) || !ACT.test(result.receipt_id) || !DECISION.test(result.decision_id) || resultIds.has(result.result_id) || receiptIds.has(result.receipt_id) || decisionIds.has(result.decision_id) || actionSequences.has(result.action_sequence) || result.action_sequence <= previousAction) return invalid(); + const resultNumber = numberSuffix(result.result_id, RESULT); const ids = result.status === "applied" ? result.caused_effect_ids.map((id) => numberSuffix(id, EFFECT)) : []; + const effectHigh = Math.max(0, ...(ids as number[])); + if (resultNumber === undefined || resultNumber < nextResult || resultNumber >= MAX_NEXT_COUNTER || ids.some((id) => id === undefined || id! < nextEffect) || effectHigh >= MAX_NEXT_COUNTER) return invalid(); + const effectWatermark = Math.max(effectWatermarks.at(-1) ?? 0, effectHigh); + add(source.principal, result, effectWatermark); nextResult = Math.max(nextResult, resultNumber + 1); nextEffect = effectWatermark + 1; + }; + const reserveBatch = (input: unknown): WorldActionResultBatchReservation => { + const source = own(input, ["actions", "effect_capacity"], true), raw = source === undefined ? undefined : array(source.actions, MAX_ENTRIES, 1); + if (live !== undefined || source === undefined || raw === undefined || !safe(source.effect_capacity, 0, DYNAMICS_LIMITS.events_per_tick) || admitted > MAX_ENTRIES - raw.length || nextResult > MAX_NEXT_COUNTER - raw.length || nextEffect > MAX_NEXT_COUNTER - source.effect_capacity) return invalid(); + const actions = raw.map((item) => { const record = own(item, ["principal", "receipt_id", "decision_id", "action_sequence", "declared_rejection_codes"]); const declared = record === undefined ? undefined : codes(record.declared_rejection_codes); return record === undefined || declared === undefined || !text(record.principal) || typeof record.receipt_id !== "string" || !ACT.test(record.receipt_id) || typeof record.decision_id !== "string" || !DECISION.test(record.decision_id) || !safe(record.action_sequence, 1) || record.receipt_id !== `world-act-${record.action_sequence}` ? undefined : frozen({ principal: record.principal, receipt_id: record.receipt_id, decision_id: record.decision_id, action_sequence: record.action_sequence, codes: declared }); }); + if (actions.some((item) => item === undefined) || new Set(actions.map((item) => item!.receipt_id)).size !== actions.length || new Set(actions.map((item) => item!.decision_id)).size !== actions.length || new Set(actions.map((item) => item!.action_sequence)).size !== actions.length || actions.some((item, index) => bindings.get(item!.principal) === undefined || receiptIds.has(item!.receipt_id) || decisionIds.has(item!.decision_id) || actionSequences.has(item!.action_sequence) || item!.action_sequence <= previousAction || (index > 0 && item!.action_sequence <= actions[index - 1]!.action_sequence))) return invalid(); + const reservation = { actions: frozen(actions as NonNullable[]), effectCapacity: source.effect_capacity, resultStart: nextResult, effectStart: nextEffect }; live = reservation; + let settled = false; + const stale = (): never => { throw new Error("stale world action result reservation"); }; + const resultId = (index: number): string => !settled && safe(index, 0, reservation.actions.length - 1) ? `world-result-${reservation.resultStart + index}` : stale(); + const effectId = (index: number): string => !settled && safe(index, 0, reservation.effectCapacity - 1) ? `world-effect-${reservation.effectStart + index}` : stale(); + const abort = (): void => { if (settled) stale(); settled = true; live = undefined; }; + const publish = (results: readonly WorldActionResult[]): void => { + if (settled || !Array.isArray(results) || results.length !== reservation.actions.length) stale(); + const effects: string[] = []; + for (let index = 0; index < results.length; index += 1) { const action = reservation.actions[index]!, result = parseWorldActionResult(results[index]) ?? stale(); if (result.result_id !== resultId(index) || result.receipt_id !== action.receipt_id || result.decision_id !== action.decision_id || result.action_sequence !== action.action_sequence || !bound(result, action.principal, action.codes) || resultIds.has(result.result_id) || receiptIds.has(result.receipt_id) || decisionIds.has(result.decision_id) || actionSequences.has(result.action_sequence)) stale(); if (result.status === "applied") for (const id of result.caused_effect_ids) if (!effects.includes(id)) effects.push(id); } + const effectNumbers = effects.map((id) => numberSuffix(id, EFFECT)); + if (effects.length > reservation.effectCapacity || effectNumbers.some((id) => id === undefined) + || new Set(effectNumbers).size !== effectNumbers.length + || effectNumbers.sort((left, right) => left! - right!).some((id, index) => id !== reservation.effectStart + index)) stale(); + const effectWatermark = reservation.effectStart + effects.length - 1; + for (let index = 0; index < results.length; index += 1) add(reservation.actions[index]!.principal, results[index]!, effectWatermark); + nextResult = reservation.resultStart + results.length; nextEffect = reservation.effectStart + effects.length; settled = true; live = undefined; + }; + return frozen({ resultId, effectId, publish, abort }); + }; + const read = (principal: string, request: unknown): WorldActionResultPage => { const target = text(principal) ? bindings.get(principal) : undefined, parsed = parseWorldActionResultPageRequest(request); if (target === undefined || parsed === undefined) return denied(); const after = parsed.result_after === undefined ? undefined : parseCursor(parsed.result_after, target); if (parsed.result_after !== undefined && after === undefined || after !== undefined && (after > pages.get(principal)! || after < evicted.get(principal)!)) return denied(); const values = entries.get(principal)!.filter((entry) => after === undefined || entry.page > after).slice(0, parsed.limit), next = values.at(-1)?.page ?? after; return frozen({ results: frozen(values.map((entry) => clone(entry.result))), ...(next === undefined ? {} : { next_result_after: makeCursor(target, next) }) }); }; + const compare = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0; + const exportState = (): LedgerSnapshotState => { if (live !== undefined) return invalid(); const ordered = [...bindings.values()].sort((left, right) => compare(left.principal, right.principal)); const numeric = (expression: RegExp) => (left: string, right: string) => numberSuffix(left, expression)! - numberSuffix(right, expression)!; return frozen({ version: WORLD_ACTION_RESULT_LEDGER_SNAPSHOT_VERSION, max_entries: maximum, max_principals: capacity, issuer, secret: secret.toString("hex"), bindings: frozen(ordered.map((item) => frozen({ ...item }))), entries: frozen(ordered.map((item) => frozen({ principal: item.principal, values: frozen(entries.get(item.principal)!.map((entry) => frozen({ page: entry.page, result: clone(entry.result) }))) }))), pages: frozen(ordered.map((item) => frozen([item.principal, pages.get(item.principal)!] as [string, number]))), evicted: frozen(ordered.map((item) => frozen([item.principal, evicted.get(item.principal)!] as [string, number]))), result_ids: frozen([...resultIds].sort(numeric(RESULT))), receipt_ids: frozen([...receiptIds].sort(numeric(ACT))), decision_ids: frozen([...decisionIds].sort(compare)), action_sequences: frozen([...actionSequences].sort((left, right) => left - right)), effect_watermarks: frozen([...effectWatermarks]), admitted, previous_action: previousAction, next_result: nextResult, next_effect: nextEffect }); }; + const importState = (state: LedgerSnapshotState): void => { if (restoreUsed || live !== undefined || bindings.size !== 0 || admitted !== 0 || state.version !== WORLD_ACTION_RESULT_LEDGER_SNAPSHOT_VERSION || state.max_entries !== maximum || state.max_principals !== capacity) return invalid(); bindings = new Map(state.bindings.map((item) => [item.principal, item])); entries = new Map(state.entries.map((item) => [item.principal, [...item.values]])); pages = new Map(state.pages); evicted = new Map(state.evicted); resultIds = new Set(state.result_ids); receiptIds = new Set(state.receipt_ids); decisionIds = new Set(state.decision_ids); actionSequences = new Set(state.action_sequences); effectWatermarks = [...state.effect_watermarks]; issuer = state.issuer; secret = Buffer.from(state.secret, "hex"); admitted = state.admitted; previousAction = state.previous_action; nextResult = state.next_result; nextEffect = state.next_effect; restoreUsed = true; }; + const authority = frozen({ append, read, reserve, reserveBatch, exportState, importState, hasLiveReservation: () => live !== undefined }); const ledger: WorldActionResultLedger = frozen({ read }); issued.set(ledger, authority); return ledger; +}; diff --git a/src/world/actionResultLedgerInspection.ts b/src/world/actionResultLedgerInspection.ts new file mode 100644 index 0000000..f450cbc --- /dev/null +++ b/src/world/actionResultLedgerInspection.ts @@ -0,0 +1,41 @@ +import { types } from "node:util"; + +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import { readWorldActionResultLedger, type WorldActionResultLedger } from "./actionResultLedger.js"; + +const registrations = new WeakMap(); +const owners = new WeakMap(); + +const plainObject = (value: unknown): value is object => + value !== null && typeof value === "object" && !types.isProxy(value as object) + && Object.getPrototypeOf(value) === Object.prototype; + +const issuedRuntime = (value: unknown): value is object => + plainObject(value) && readWorldRuntimeClockAuthority(value) !== undefined; + +const issuedLedger = (value: unknown): value is WorldActionResultLedger => { + if (!plainObject(value) || readWorldActionResultLedger(value) === undefined) return false; + const keys = Reflect.ownKeys(value); + if (keys.length !== 1 || keys[0] !== "read") return false; + const descriptor = Object.getOwnPropertyDescriptor(value, "read"); + return descriptor?.enumerable === true && descriptor.writable === false + && descriptor.configurable === false && "value" in descriptor + && typeof descriptor.value === "function" && Object.isFrozen(value); +}; + +/** Host-only, one-shot binding of an issued runtime to its issued public result handle. */ +export const registerWorldRuntimeActionResultLedgerInspection = ( + runtime: object, + ledger: WorldActionResultLedger, +): void => { + if (!issuedRuntime(runtime) || !issuedLedger(ledger) || registrations.has(runtime) || owners.has(ledger)) { + throw new Error("invalid world action result ledger inspection registration"); + } + registrations.set(runtime, ledger); + owners.set(ledger, runtime); +}; + +export const readWorldRuntimeActionResultLedgerInspection = ( + runtime: unknown, +): WorldActionResultLedger | undefined => + issuedRuntime(runtime) ? registrations.get(runtime) : undefined; diff --git a/src/world/actionResultLedgerRestore.test.ts b/src/world/actionResultLedgerRestore.test.ts new file mode 100644 index 0000000..c80ad12 --- /dev/null +++ b/src/world/actionResultLedgerRestore.test.ts @@ -0,0 +1,212 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createWorldActionResultLedger, readWorldActionResultLedger, WORLD_ACTION_RESULT_PAGE_REQUEST_VERSION } from "./actionResultLedger.js"; +import { parseWorldActionResultLedgerSnapshot, restoreWorldActionResultStore, snapshotWorldActionResultStore } from "./actionResultLedgerSnapshot.js"; + +const binding = Object.freeze({ principal: "p", actor: "world://world/entity/p", run_id: "run", world_id: "world", world_instance_id: "instance", manifest_digest: `sha256:${"a".repeat(64)}` }); +const result = Object.freeze({ version: "simfile.world-action-result.v1" as const, result_id: "world-result-1", receipt_id: "world-act-1", decision_id: "decision-000000000001", actor: binding.actor, action_sequence: 1, apply_tick: 0, status: "applied" as const, caused_effect_ids: Object.freeze(["world-effect-1"]), identity: Object.freeze({ run_id: binding.run_id, world_id: binding.world_id, world_instance_id: binding.world_instance_id, manifest_digest: binding.manifest_digest, state_version: 2 }) }); +const request = (result_after?: unknown) => ({ version: WORLD_ACTION_RESULT_PAGE_REQUEST_VERSION, ...(result_after === undefined ? {} : { result_after }) }); + +test("round-trips standalone state and keeps pre-snapshot cursors valid", () => { + const source = createWorldActionResultLedger(); const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding] }); writer.append({ principal: "p", result }); const cursor = source.read("p", request()).next_result_after!; + const snapshot = snapshotWorldActionResultStore(source); const restored = createWorldActionResultLedger(); restoreWorldActionResultStore(restored, snapshot); + assert.equal(restored.read("p", request(cursor)).results.length, 0); assert.throws(() => restoreWorldActionResultStore(restored, snapshot)); + const hostile = { ...(snapshot as Record), secret: "0" }; assert.throws(() => restoreWorldActionResultStore(createWorldActionResultLedger(), hostile)); + assert.throws(() => restoreWorldActionResultStore(createWorldActionResultLedger({ maxEntriesPerPrincipal: 1 }), snapshot)); +}); + +test("rejects detached hostile snapshot graphs without consuming a pristine restore", () => { + const source = createWorldActionResultLedger(); const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding] }); writer.append({ principal: "p", result }); + const snapshot = snapshotWorldActionResultStore(source) as Record; + const accessor = { ...snapshot }; Object.defineProperty(accessor, "secret", { enumerable: true, get: () => { throw new Error("read"); } }); + const alias = structuredClone(snapshot) as Record; alias.entries = alias.bindings; + const sparse = structuredClone(snapshot) as Record; const ids = sparse.result_ids as unknown[]; ids.length = 2; + for (const hostile of [accessor, new Proxy(snapshot, {}), alias, sparse, { ...snapshot, next_result: 1 }]) { + const target = createWorldActionResultLedger(); assert.throws(() => restoreWorldActionResultStore(target, hostile)); + restoreWorldActionResultStore(target, snapshot); assert.equal(target.read("p", request()).results.length, 1); + } + assert.ok(Object.isFrozen(parseWorldActionResultLedgerSnapshot(snapshot))); +}); + +test("preflights cumulative retained lengths before reading numeric elements", () => { + const q = { ...binding, principal: "q", actor: "world://world/entity/q" }; + const source = createWorldActionResultLedger({ maxEntriesPerPrincipal: 6_000, maxPrincipals: 2 }); + const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding, q] }); + for (let index = 1; index <= 6_000; index += 1) writer.append({ principal: index % 2 === 0 ? "q" : "p", result: { ...result, actor: index % 2 === 0 ? q.actor : binding.actor, result_id: `world-result-${index}`, receipt_id: `world-act-${index}`, decision_id: `decision-${String(index).padStart(12, "0")}`, action_sequence: index, caused_effect_ids: [] } }); + const snapshot = structuredClone(snapshotWorldActionResultStore(source)) as Record; + snapshot.admitted = 5_999; + const entries = snapshot.entries as Array<{ values: unknown[] }>; + let read = 0; const values = entries[0]!.values as unknown[], first = values[0]; + Object.defineProperty(values, "0", { enumerable: true, get: () => { read += 1; return first; } }); + const original = Object.getOwnPropertyDescriptor; let numericDescriptors = 0; + (Object as { getOwnPropertyDescriptor: typeof Object.getOwnPropertyDescriptor }).getOwnPropertyDescriptor = (target, key) => { + if (target === values && key === "0") numericDescriptors += 1; + return original(target, key); + }; + try { assert.throws(() => parseWorldActionResultLedgerSnapshot(snapshot)); } + finally { (Object as { getOwnPropertyDescriptor: typeof Object.getOwnPropertyDescriptor }).getOwnPropertyDescriptor = original; } + assert.equal(read, 0); assert.equal(numericDescriptors, 0); +}); + +test("rejects dense-list extras, holes, and accessors before a pristine restore", () => { + const source = createWorldActionResultLedger(); const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding] }); writer.append({ principal: "p", result }); + const pristine = snapshotWorldActionResultStore(source) as Record; + const hostile = [ + () => { const value = structuredClone(pristine) as Record; Object.defineProperty(value.result_ids as object, Symbol("extra"), { value: 1 }); return value; }, + () => { const value = structuredClone(pristine) as Record; delete (value.bindings as unknown[])[0]; return value; }, + ]; + for (const make of hostile) { const target = createWorldActionResultLedger(); assert.throws(() => restoreWorldActionResultStore(target, make())); restoreWorldActionResultStore(target, pristine); assert.equal(target.read("p", request()).results.length, 1); } + const accessor = structuredClone(pristine) as Record; let reads = 0; + Object.defineProperty(accessor.pages as object, "0", { enumerable: true, get: () => { reads += 1; return ["p", 1]; } }); + const target = createWorldActionResultLedger(); assert.throws(() => restoreWorldActionResultStore(target, accessor)); assert.equal(reads, 0); restoreWorldActionResultStore(target, pristine); assert.equal(target.read("p", request()).results.length, 1); +}); + +test("keeps result and effect counters at their safe exhausted boundary", () => { + const ceiling = Number.MAX_SAFE_INTEGER - 1, last = ceiling - 1; + for (const [kind, accepted, rejected, key] of [ + ["result", { ...result, result_id: `world-result-${last}`, caused_effect_ids: ["world-effect-1"] }, { ...result, result_id: `world-result-${ceiling}`, receipt_id: "world-act-2", decision_id: "decision-000000000002", action_sequence: 2, caused_effect_ids: ["world-effect-2"] }, "next_result"], + ["effect", { ...result, caused_effect_ids: [`world-effect-${last}`] }, { ...result, result_id: "world-result-2", receipt_id: "world-act-2", decision_id: "decision-000000000002", action_sequence: 2, caused_effect_ids: [`world-effect-${ceiling}`] }, "next_effect"], + ] as const) { + const source = createWorldActionResultLedger(), writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding] }); writer.append({ principal: "p", result: accepted }); + const snapshot = snapshotWorldActionResultStore(source) as Record; assert.equal(snapshot[key], ceiling); assert.doesNotThrow(() => parseWorldActionResultLedgerSnapshot(snapshot)); + const restored = createWorldActionResultLedger(); restoreWorldActionResultStore(restored, snapshot); const next = readWorldActionResultLedger(restored)!, before = snapshotWorldActionResultStore(restored), visible = restored.read("p", request()).results; + assert.equal(visible[0]!.result_id, accepted.result_id); assert.deepEqual(visible[0]!.status === "applied" ? visible[0]!.caused_effect_ids : [], accepted.caused_effect_ids); + assert.throws(() => next.append({ principal: "p", result: rejected }), kind); assert.deepEqual(snapshotWorldActionResultStore(restored), before); + assert.throws(() => next.reserveBatch({ actions: [{ principal: "p", receipt_id: "world-act-2", decision_id: "decision-000000000002", action_sequence: 2, declared_rejection_codes: [] }], effect_capacity: 1 }), kind); + assert.deepEqual(snapshotWorldActionResultStore(restored), before); assert.deepEqual(restored.read("p", request()).results, visible); assert.doesNotThrow(() => snapshotWorldActionResultStore(restored)); + } +}); + +test("keeps evicted effect high water and rejects hostile watermark evidence", () => { + const source = createWorldActionResultLedger({ maxEntriesPerPrincipal: 1 }), writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding] }); + const rejected = (({ caused_effect_ids: _effects, ...value }) => ({ ...value, result_id: "world-result-2", receipt_id: "world-act-2", decision_id: "decision-000000000002", action_sequence: 2, status: "rejected_at_mechanics" as const, rejection_code: "world_action_rejected" }))(result); + writer.append({ principal: "p", result: { ...result, caused_effect_ids: ["world-effect-100"] } }); writer.append({ principal: "p", result: rejected }); + const pristine = snapshotWorldActionResultStore(source) as Record; assert.deepEqual(pristine.effect_watermarks, [100, 100]); assert.equal(pristine.next_effect, 101); + const target = createWorldActionResultLedger({ maxEntriesPerPrincipal: 1 }), rollback = structuredClone(pristine) as Record; rollback.next_effect = 1; + assert.throws(() => restoreWorldActionResultStore(target, rollback)); restoreWorldActionResultStore(target, pristine); + const next = readWorldActionResultLedger(target)!, batch = next.reserveBatch({ actions: [{ principal: "p", receipt_id: "world-act-3", decision_id: "decision-000000000003", action_sequence: 3, declared_rejection_codes: [] }], effect_capacity: 100 }); + assert.equal(batch.effectId(0), "world-effect-101"); batch.publish([{ ...result, result_id: batch.resultId(0), receipt_id: "world-act-3", decision_id: "decision-000000000003", action_sequence: 3, caused_effect_ids: Array.from({ length: 100 }, (_, index) => batch.effectId(index)) }]); + const hostile = (change: (value: Record) => void): void => { const value = structuredClone(pristine) as Record; change(value); const fresh = createWorldActionResultLedger({ maxEntriesPerPrincipal: 1 }); assert.throws(() => restoreWorldActionResultStore(fresh, value)); restoreWorldActionResultStore(fresh, pristine); }; + hostile((x) => { (x.effect_watermarks as unknown[]).pop(); }); hostile((x) => { (x.effect_watermarks as unknown[]).push(100); }); hostile((x) => { delete (x.effect_watermarks as unknown[])[0]; }); + hostile((x) => { Object.defineProperty(x.effect_watermarks as object, "0", { enumerable: true, get: () => 100 }); }); hostile((x) => { Object.defineProperty(x.effect_watermarks as object, Symbol("x"), { value: 1 }); }); hostile((x) => { x.effect_watermarks = new Proxy(x.effect_watermarks as object, {}); }); hostile((x) => { Object.setPrototypeOf(x.effect_watermarks as object, {}); }); + hostile((x) => { x.effect_watermarks = [100, 99]; }); hostile((x) => { x.effect_watermarks = [100, 1.5]; }); hostile((x) => { x.effect_watermarks = [-1, 100]; }); hostile((x) => { x.effect_watermarks = [100, Number.MAX_SAFE_INTEGER - 1]; }); hostile((x) => { x.next_effect = 1; }); + const retained = createWorldActionResultLedger({ maxEntriesPerPrincipal: 2 }), retainedWriter = readWorldActionResultLedger(retained)!; retainedWriter.reserve({ bindings: [binding] }); retainedWriter.append({ principal: "p", result: { ...result, caused_effect_ids: ["world-effect-100"] } }); retainedWriter.append({ principal: "p", result: rejected }); + const retainedPristine = snapshotWorldActionResultStore(retained) as Record, above = structuredClone(retainedPristine) as Record; above.effect_watermarks = [99, 100]; const retainedTarget = createWorldActionResultLedger({ maxEntriesPerPrincipal: 2 }); assert.throws(() => restoreWorldActionResultStore(retainedTarget, above)); restoreWorldActionResultStore(retainedTarget, retainedPristine); +}); + +test("records append and shared-batch watermarks atomically", () => { + const ledger = createWorldActionResultLedger(), writer = readWorldActionResultLedger(ledger)!; writer.reserve({ bindings: [binding] }); assert.deepEqual((snapshotWorldActionResultStore(ledger) as Record).effect_watermarks, []); assert.equal((snapshotWorldActionResultStore(ledger) as Record).next_effect, 1); + const rejected = (({ caused_effect_ids: _effects, ...value }) => ({ ...value, result_id: "world-result-2", receipt_id: "world-act-2", decision_id: "decision-000000000002", action_sequence: 2, status: "rejected_at_mechanics" as const, rejection_code: "world_action_rejected" }))(result); + writer.append({ principal: "p", result: { ...result, caused_effect_ids: ["world-effect-5"] } }); writer.append({ principal: "p", result: rejected }); + const actions = [3, 4].map((action_sequence) => ({ principal: "p", receipt_id: `world-act-${action_sequence}`, decision_id: `decision-${String(action_sequence).padStart(12, "0")}`, action_sequence, declared_rejection_codes: [] })); const batch = writer.reserveBatch({ actions, effect_capacity: 3 }), first = batch.effectId(0), second = batch.effectId(1); + batch.publish([{ ...result, result_id: batch.resultId(0), receipt_id: "world-act-3", decision_id: "decision-000000000003", action_sequence: 3, caused_effect_ids: [first, second] }, { ...result, result_id: batch.resultId(1), receipt_id: "world-act-4", decision_id: "decision-000000000004", action_sequence: 4, caused_effect_ids: [second] }]); + const before = snapshotWorldActionResultStore(ledger) as Record; assert.deepEqual(before.effect_watermarks, [5, 5, 7, 7]); assert.equal(before.next_effect, 8); + const restored = createWorldActionResultLedger(); restoreWorldActionResultStore(restored, before); assert.deepEqual((snapshotWorldActionResultStore(restored) as Record).effect_watermarks, [5, 5, 7, 7]); + assert.throws(() => writer.append({ principal: "p", result: { ...result, result_id: "world-result-5", receipt_id: "world-act-5", decision_id: "decision-000000000005", action_sequence: 5, caused_effect_ids: ["wrong"] } })); assert.deepEqual(snapshotWorldActionResultStore(ledger), before); + const aborted = writer.reserveBatch({ actions: [{ principal: "p", receipt_id: "world-act-5", decision_id: "decision-000000000005", action_sequence: 5, declared_rejection_codes: [] }], effect_capacity: 1 }); aborted.abort(); assert.deepEqual(snapshotWorldActionResultStore(ledger), before); + const failed = writer.reserveBatch({ actions: [{ principal: "p", receipt_id: "world-act-5", decision_id: "decision-000000000005", action_sequence: 5, declared_rejection_codes: [] }], effect_capacity: 1 }); assert.throws(() => failed.publish([])); assert.equal(failed.resultId(0), "world-result-5"); failed.abort(); assert.deepEqual(snapshotWorldActionResultStore(ledger), before); +}); + +test("rejects every nested hostile graph and restores the pristine snapshot afterward", () => { + const source = createWorldActionResultLedger(); const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding] }); + writer.append({ principal: "p", result }); writer.append({ principal: "p", result: { ...result, result_id: "world-result-2", receipt_id: "world-act-2", decision_id: "decision-000000000002", action_sequence: 2, caused_effect_ids: ["world-effect-2"] } }); + const pristine = snapshotWorldActionResultStore(source) as Record; + const make = (): Record => structuredClone(pristine) as Record; + const cases: Record Record> = { + "shared identity alias": () => { const x = make(), values = (x.entries as Array<{ values: Array<{ result: Record }> }>)[0]!.values; values[1]!.result.identity = values[0]!.result.identity; return x; }, + "shared caused_effect_ids alias": () => { const x = make(), values = (x.entries as Array<{ values: Array<{ result: Record }> }>)[0]!.values; values[1]!.result.caused_effect_ids = values[0]!.result.caused_effect_ids; return x; }, + "own thenable": () => { const x = make(); Object.defineProperty(((x.entries as Array<{ values: Array<{ result: Record }> }>)[0]!.values[0]!.result.identity), "then", { enumerable: true, value: () => {} }); return x; }, + "custom prototype": () => { const x = make(); Object.setPrototypeOf((x.entries as Array<{ values: unknown[] }>)[0]!.values[0], { }); return x; }, + "sparse nested array": () => { const x = make(), values = (x.entries as Array<{ values: unknown[] }>)[0]!.values; delete values[1]; return x; }, + "nested accessor": () => { const x = make(); Object.defineProperty((x.entries as Array<{ values: Array<{ result: Record }> }>)[0]!.values[1]!.result, "actor", { enumerable: true, get: () => binding.actor }); return x; }, + "nested symbol key": () => { const x = make(); Object.defineProperty((x.entries as Array<{ values: unknown[] }>)[0]!.values[1], Symbol("hostile"), { value: 1 }); return x; }, + "nested proxy": () => { const x = make(), values = (x.entries as Array<{ values: unknown[] }>)[0]!.values; values[1] = new Proxy(values[1]!, {}); return x; }, + }; + for (const [name, hostile] of Object.entries(cases)) { const target = createWorldActionResultLedger(); assert.throws(() => restoreWorldActionResultStore(target, hostile()), name); restoreWorldActionResultStore(target, pristine); assert.equal(target.read("p", request()).results.length, 2); } +}); + +test("rejects a structurally aligned maximum-safe result suffix before consuming restore", () => { + const source = createWorldActionResultLedger(); const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding] }); writer.append({ principal: "p", result }); + const pristine = snapshotWorldActionResultStore(source) as Record; + const hostile = structuredClone(pristine) as Record, hostileId = `world-result-${Number.MAX_SAFE_INTEGER}`; + (hostile.result_ids as unknown[])[0] = hostileId; + (((hostile.entries as Array<{ values: Array<{ result: Record }> }>)[0]!.values[0]!).result).result_id = hostileId; + const target = createWorldActionResultLedger(); assert.throws(() => restoreWorldActionResultStore(target, hostile)); + restoreWorldActionResultStore(target, pristine); assert.equal(target.read("p", request()).results[0]!.result_id, "world-result-1"); +}); + +test("rejects cumulative page totals before consuming a pristine restore", () => { + const q = { ...binding, principal: "q", actor: "world://world/entity/q" }; + const source = createWorldActionResultLedger({ maxPrincipals: 2 }); const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding, q] }); + writer.append({ principal: "p", result }); writer.append({ principal: "q", result: { ...result, actor: q.actor, result_id: "world-result-2", receipt_id: "world-act-2", decision_id: "decision-000000000002", action_sequence: 2, caused_effect_ids: [] } }); + const pristine = snapshotWorldActionResultStore(source) as Record, hostile = structuredClone(pristine) as Record; + hostile.admitted = 1; + const target = createWorldActionResultLedger({ maxPrincipals: 2 }); assert.throws(() => restoreWorldActionResultStore(target, hostile)); + restoreWorldActionResultStore(target, pristine); assert.equal(target.read("p", request()).results.length, 1); +}); + +test("parses and restores an exact 10,000-record store while rejecting one-over arrays", () => { + const source = createWorldActionResultLedger({ maxEntriesPerPrincipal: 10_000 }); const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding] }); + for (let index = 1; index <= 10_000; index += 1) writer.append({ principal: "p", result: { ...result, result_id: `world-result-${index}`, receipt_id: `world-act-${index}`, decision_id: `decision-${String(index).padStart(12, "0")}`, action_sequence: index, caused_effect_ids: [`world-effect-${index}`] } }); + const snapshot = snapshotWorldActionResultStore(source) as Record; const restored = createWorldActionResultLedger({ maxEntriesPerPrincipal: 10_000 }); restoreWorldActionResultStore(restored, snapshot); + assert.equal(restored.read("p", request()).results.length, 50); + const oneOver = structuredClone(snapshot) as Record; (oneOver.result_ids as unknown[]).push("world-result-10001"); assert.throws(() => parseWorldActionResultLedgerSnapshot(oneOver)); +}); + +test("uses v1 snapshots without a global effect-id set and preserves custom configuration", () => { + const source = createWorldActionResultLedger({ maxEntriesPerPrincipal: 2, maxPrincipals: 1 }); const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding] }); + writer.append({ principal: "p", result: { ...result, caused_effect_ids: Array.from({ length: 256 }, (_, index) => `world-effect-${index + 1}`) } }); + const snapshot = snapshotWorldActionResultStore(source) as Record; + assert.equal(snapshot.version, "simfile.world-action-result-ledger.v1"); assert.equal("effect_ids" in snapshot, false); + const restored = createWorldActionResultLedger({ maxEntriesPerPrincipal: 2, maxPrincipals: 1 }); restoreWorldActionResultStore(restored, snapshot); + assert.equal(restored.read("p", request()).results[0]!.status, "applied"); + assert.throws(() => restoreWorldActionResultStore(createWorldActionResultLedger({ maxEntriesPerPrincipal: 2, maxPrincipals: 2 }), snapshot)); +}); + +test("rejects evicted receipt/action disagreement and does not consume a failed restore", () => { + const source = createWorldActionResultLedger({ maxEntriesPerPrincipal: 1 }); const writer = readWorldActionResultLedger(source)!; + writer.reserve({ bindings: [binding] }); writer.append({ principal: "p", result }); writer.append({ principal: "p", result: { ...result, result_id: "world-result-2", receipt_id: "world-act-2", decision_id: "decision-000000000002", action_sequence: 2, caused_effect_ids: ["world-effect-2"] } }); + const snapshot = snapshotWorldActionResultStore(source) as Record, hostile = structuredClone(snapshot) as Record; + hostile.receipt_ids = ["world-act-2", "world-act-3"]; + const target = createWorldActionResultLedger({ maxEntriesPerPrincipal: 1 }); assert.throws(() => restoreWorldActionResultStore(target, hostile)); + restoreWorldActionResultStore(target, snapshot); assert.equal(target.read("p", request()).results[0]!.receipt_id, "world-act-2"); +}); + +test("rejects an extra deep result before walking it", () => { + const source = createWorldActionResultLedger(); const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding] }); writer.append({ principal: "p", result }); + const hostile = structuredClone(snapshotWorldActionResultStore(source)) as Record; + const raw = ((hostile.entries as Array<{ values: Array<{ result: Record }> }>)[0]!.values[0]!.result); let touched = 0; + Object.defineProperty(raw, "extra", { enumerable: true, get: () => { touched += 1; return { nested: raw }; } }); + assert.throws(() => parseWorldActionResultLedgerSnapshot(hostile)); assert.equal(touched, 0); +}); + +test("preflights retained value descriptors and cumulative admissions before values", () => { + const source = createWorldActionResultLedger(); const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding] }); writer.append({ principal: "p", result }); + const snapshot = snapshotWorldActionResultStore(source) as Record; + const accessor = structuredClone(snapshot) as Record, values = ((accessor.entries as Array<{ values: unknown[] }>)[0]!.values); let touched = 0; + Object.defineProperty(values, "0", { enumerable: true, get: () => { touched += 1; return {}; } }); + assert.throws(() => parseWorldActionResultLedgerSnapshot(accessor)); assert.equal(touched, 0); + const over = structuredClone(snapshot) as Record; over.bindings = [binding, { ...binding, principal: "q", actor: "world://world/entity/q" }]; over.entries = [...(over.entries as unknown[]), { principal: "q", values: [] }]; over.pages = [...(over.pages as unknown[]), ["q", 1]]; over.evicted = [...(over.evicted as unknown[]), ["q", 1]]; + assert.throws(() => parseWorldActionResultLedgerSnapshot(over)); +}); + +test("refuses a live batch snapshot, then aborts without an id gap", () => { + const ledger = createWorldActionResultLedger(), writer = readWorldActionResultLedger(ledger)!; writer.reserve({ bindings: [binding] }); + const batch = writer.reserveBatch({ actions: [{ principal: "p", receipt_id: "world-act-1", decision_id: "decision-000000000001", action_sequence: 1, declared_rejection_codes: [] }], effect_capacity: 1 }); + assert.throws(() => snapshotWorldActionResultStore(ledger)); batch.abort(); assert.doesNotThrow(() => snapshotWorldActionResultStore(ledger)); + assert.equal(writer.reserveBatch({ actions: [{ principal: "p", receipt_id: "world-act-1", decision_id: "decision-000000000001", action_sequence: 1, declared_rejection_codes: [] }], effect_capacity: 1 }).resultId(0), "world-result-1"); +}); + +test("restores independent evicted principals, global evidence, and high effect water", () => { + const q = { ...binding, principal: "q", actor: "world://world/entity/q" }, source = createWorldActionResultLedger({ maxEntriesPerPrincipal: 1 }); const writer = readWorldActionResultLedger(source)!; writer.reserve({ bindings: [binding, q] }); + const one = result, two = { ...result, result_id: "world-result-2", receipt_id: "world-act-2", decision_id: "decision-000000000002", actor: q.actor, action_sequence: 2, caused_effect_ids: ["world-effect-50001"] }, three = { ...result, result_id: "world-result-3", receipt_id: "world-act-3", decision_id: "decision-000000000003", action_sequence: 3, caused_effect_ids: ["world-effect-50002"] }; + writer.append({ principal: "p", result: one }); writer.append({ principal: "q", result: two }); writer.append({ principal: "p", result: three }); const restored = createWorldActionResultLedger({ maxEntriesPerPrincipal: 1 }); restoreWorldActionResultStore(restored, snapshotWorldActionResultStore(source)); const next = readWorldActionResultLedger(restored)!; + assert.deepEqual(restored.read("p", request()).results.map((item) => item.receipt_id), ["world-act-3"]); assert.deepEqual(restored.read("q", request()).results.map((item) => item.receipt_id), ["world-act-2"]); + for (const duplicate of [{ ...three, result_id: one.result_id, receipt_id: "world-act-4", decision_id: "decision-000000000004", action_sequence: 4 }, { ...three, result_id: "world-result-4", receipt_id: one.receipt_id, decision_id: "decision-000000000004", action_sequence: 4 }, { ...three, result_id: "world-result-4", receipt_id: "world-act-4", decision_id: one.decision_id, action_sequence: 4 }, { ...three, result_id: "world-result-4", receipt_id: "world-act-3", decision_id: "decision-000000000004", action_sequence: 3 }]) assert.throws(() => next.append({ principal: "p", result: duplicate })); + const batch = next.reserveBatch({ actions: [{ principal: "p", receipt_id: "world-act-4", decision_id: "decision-000000000004", action_sequence: 4, declared_rejection_codes: [] }], effect_capacity: 1 }); assert.equal(batch.effectId(0), "world-effect-50003"); batch.abort(); +}); + +test("exports principals in deterministic UTF-16 order", () => { + const first = { ...binding, principal: "Ω" }, second = { ...binding, principal: "é", actor: "world://world/entity/e" }, ledger = createWorldActionResultLedger(); const writer = readWorldActionResultLedger(ledger)!; writer.reserve({ bindings: [first, second] }); + assert.deepEqual((snapshotWorldActionResultStore(ledger) as { bindings: Array<{ principal: string }> }).bindings.map((item) => item.principal), ["é", "Ω"]); +}); diff --git a/src/world/actionResultLedgerSnapshot.ts b/src/world/actionResultLedgerSnapshot.ts new file mode 100644 index 0000000..860ccc1 --- /dev/null +++ b/src/world/actionResultLedgerSnapshot.ts @@ -0,0 +1,124 @@ +import { types } from "node:util"; + +import { + readWorldActionResultLedger, WORLD_ACTION_RESULT_LEDGER_SNAPSHOT_VERSION as SNAPSHOT_VERSION, + type LedgerBinding, type LedgerEntry, type LedgerSnapshotState, type WorldActionResultLedger, +} from "./actionResultLedger.js"; +import { parseWorldActionResult, type WorldActionResult } from "./actionResult.js"; + +export const WORLD_ACTION_RESULT_LEDGER_SNAPSHOT_VERSION = SNAPSHOT_VERSION; +const MAX_RECORDS = 10_000, MAX_PRINCIPALS = 4_096; +const MAX_NEXT_COUNTER = Number.MAX_SAFE_INTEGER - 1, MAX_EFFECTS = 256; +const RESULT = /^world-result-([1-9][0-9]*)$/u, ACT = /^world-act-([1-9][0-9]*)$/u, DECISION = /^decision-([0-9]{12})$/u, EFFECT = /^world-effect-([1-9][0-9]*)$/u; +const SHA256 = /^sha256:[a-f0-9]{64}$/u, ADDRESS = /^world:\/\/(?:[a-z][a-z0-9]*(?:-[a-z0-9]+)*\/)+entity\/[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/u; +const frozen = (value: T): T => Object.freeze(value); +const fail = (): never => { throw new TypeError("invalid action result ledger snapshot"); }; +const safe = (value: unknown, min: number, max = Number.MAX_SAFE_INTEGER): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= min && value <= max; +const text = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256 && value === value.trim(); +const compare = (left: string, right: string): number => left < right ? -1 : left > right ? 1 : 0; +const suffix = (value: string, expression: RegExp): number | undefined => { const match = expression.exec(value); const valueNumber = match === null ? Number.NaN : Number(match[1]); return safe(valueNumber, 1) ? valueNumber : undefined; }; +type Seen = Set; + +const claim = (value: unknown, seen: Seen): void => { + if (value === null || typeof value !== "object" || types.isProxy(value) || seen.has(value)) fail(); + seen.add(value as object); + if (Object.getOwnPropertyDescriptor(value as object, "then") !== undefined) fail(); +}; +const object = (value: unknown, fields: readonly string[], seen: Seen, claimed = false): Record => { + if (!claimed) claim(value, seen); if (Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) fail(); + const source = value as object, keys = Reflect.ownKeys(source); if (keys.length !== fields.length || keys.some((key) => typeof key !== "string" || !fields.includes(key))) fail(); + const output: Record = Object.create(null); + for (const key of fields) { const descriptor = Object.getOwnPropertyDescriptor(source, key); output[key] = descriptor?.enumerable && "value" in descriptor ? descriptor.value : fail(); } + return output; +}; +/** Claims retained values before global admission is known; do not inspect elements. */ +const retainedValuesShape = (value: unknown, max: number, seen: Seen): number => { + claim(value, seen); if (!Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) fail(); + const length = Object.getOwnPropertyDescriptor(value as object, "length")?.value; + if (!safe(length, 0, max)) fail(); + return length; +}; +const denseArray = (value: unknown, length: number): void => { + const source = value as object, keys = Reflect.ownKeys(source); + if (keys.length !== length + 1) fail(); + for (let index = 0; index < length; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(source, String(index)); if (!descriptor?.enumerable || !("value" in descriptor)) fail(); } +}; +const arrayShape = (value: unknown, max: number, seen: Seen): number => { + const length = retainedValuesShape(value, max, seen); denseArray(value, length); return length; +}; +/** Reads a previously shape-checked array only after its exact cap is known. */ +const list = (value: unknown, max: number, seen: Seen, claimed = false): readonly unknown[] => { + const length = claimed ? Object.getOwnPropertyDescriptor(value as object, "length")?.value : arrayShape(value, max, seen); + if (length > max) fail(); + if (claimed) denseArray(value, length); + const source = value as unknown[]; + const output: unknown[] = []; + for (let index = 0; index < length; index += 1) output.push(Object.getOwnPropertyDescriptor(source, String(index))!.value); + return output; +}; +/** Copies only the public result schema; hostile extras are rejected before descent. */ +const resultCopy = (value: unknown, seen: Seen): WorldActionResult => { + claim(value, seen); if (Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null)) fail(); + const keys = Reflect.ownKeys(value as object); if (keys.length !== 10 || keys.some((key) => typeof key !== "string")) fail(); + const status = Object.getOwnPropertyDescriptor(value as object, "status"), statusValue = status?.value; + if (!status?.enumerable || !("value" in status) || (statusValue !== "applied" && statusValue !== "rejected_at_mechanics")) fail(); + const source = object(value, statusValue === "applied" ? ["version", "result_id", "receipt_id", "decision_id", "actor", "action_sequence", "apply_tick", "status", "caused_effect_ids", "identity"] : ["version", "result_id", "receipt_id", "decision_id", "actor", "action_sequence", "apply_tick", "status", "rejection_code", "identity"], seen, true); + const identity = object(source.identity, ["run_id", "world_id", "world_instance_id", "manifest_digest", "state_version"], seen); + const base = { version: source.version, result_id: source.result_id, receipt_id: source.receipt_id, decision_id: source.decision_id, actor: source.actor, action_sequence: source.action_sequence, apply_tick: source.apply_tick, status: source.status, identity: frozen({ ...identity }) }; + if (source.status === "applied") { + const effects = list(source.caused_effect_ids, MAX_EFFECTS, seen); + if (!effects.every((effect) => typeof effect === "string")) fail(); + return parseWorldActionResult(frozen({ ...base, status: "applied" as const, caused_effect_ids: frozen([...effects] as string[]) })) ?? fail(); + } + return parseWorldActionResult(frozen({ ...base, status: "rejected_at_mechanics" as const, rejection_code: source.rejection_code })) ?? fail(); +}; +const binding = (value: unknown, seen: Seen): LedgerBinding => { + const source = object(value, ["principal", "actor", "run_id", "world_id", "world_instance_id", "manifest_digest"], seen); + if (!text(source.principal) || typeof source.actor !== "string" || !ADDRESS.test(source.actor) || !text(source.run_id) || !text(source.world_id) || !text(source.world_instance_id) || typeof source.manifest_digest !== "string" || !SHA256.test(source.manifest_digest) || !source.actor.startsWith(`world://${source.world_id}/entity/`)) fail(); + return frozen({ principal: source.principal as string, actor: source.actor as string, run_id: source.run_id as string, world_id: source.world_id as string, world_instance_id: source.world_instance_id as string, manifest_digest: source.manifest_digest as string }); +}; +const pair = (value: unknown, seen: Seen, admitted: number): readonly [string, number] => { const values = list(value, 2, seen); return values.length === 2 && typeof values[0] === "string" && safe(values[1], 0, admitted) ? frozen([values[0], values[1]]) : fail(); }; +const ids = (value: unknown, expression: RegExp, count: number, seen: Seen): readonly string[] => { const values = list(value, MAX_RECORDS, seen); if (values.length !== count || !values.every((item) => typeof item === "string" && expression.test(item))) fail(); const parsed = values.map((item) => suffix(item as string, expression)); if (parsed.some((item) => item === undefined) || new Set(values).size !== values.length || parsed.some((item, index) => index > 0 && item! <= parsed[index - 1]!)) fail(); return frozen([...values] as string[]); }; +const sequences = (value: unknown, count: number, seen: Seen): readonly number[] => { const values = list(value, MAX_RECORDS, seen); if (values.length !== count || !values.every((item) => safe(item, 1)) || new Set(values).size !== values.length || values.some((item, index) => index > 0 && item <= values[index - 1]!)) fail(); return frozen([...values] as number[]); }; +const watermarks = (value: unknown, count: number, seen: Seen): readonly number[] => { const values = list(value, MAX_RECORDS, seen); if (values.length !== count || !values.every((item) => safe(item, 0, MAX_NEXT_COUNTER - 1)) || values.some((item, index) => index > 0 && item < values[index - 1]!)) fail(); return frozen([...values] as number[]); }; + +/** Parses bounded parts independently; no whole-snapshot JSON budget limits reachable state. */ +export const parseWorldActionResultLedgerSnapshot = (input: unknown): LedgerSnapshotState => { + const seen: Seen = new Set(); + try { + const source = object(input, ["version", "max_entries", "max_principals", "issuer", "secret", "bindings", "entries", "pages", "evicted", "result_ids", "receipt_ids", "decision_ids", "action_sequences", "effect_watermarks", "admitted", "previous_action", "next_result", "next_effect"], seen); + if (source.version !== SNAPSHOT_VERSION || !safe(source.max_entries, 1, MAX_RECORDS) || !safe(source.max_principals, 1, MAX_PRINCIPALS) || typeof source.issuer !== "string" || !/^[a-f0-9]{32}$/u.test(source.issuer) || typeof source.secret !== "string" || !/^[a-f0-9]{64}$/u.test(source.secret) || !safe(source.admitted, 0, MAX_RECORDS) || !safe(source.previous_action, 0) || !safe(source.next_result, 1, MAX_NEXT_COUNTER) || !safe(source.next_effect, 1, MAX_NEXT_COUNTER)) fail(); + const maxEntries = source.max_entries as number, admitted = source.admitted as number, bindings = list(source.bindings, source.max_principals as number, seen).map((item) => binding(item, seen)); + const rawEntries = list(source.entries, source.max_principals as number, seen), pages = list(source.pages, source.max_principals as number, seen).map((item) => pair(item, seen, admitted)), evicted = list(source.evicted, source.max_principals as number, seen).map((item) => pair(item, seen, admitted)); + if (bindings.length !== rawEntries.length || bindings.length !== pages.length || bindings.length !== evicted.length || new Set(bindings.map((item) => item.principal)).size !== bindings.length || bindings.some((item, index) => index > 0 && compare(item.principal, bindings[index - 1]!.principal) <= 0)) fail(); + const retained = rawEntries.map((raw, index) => { + const item = object(raw, ["principal", "values"], seen), target = bindings[index]!, page = pages[index]!, frontier = evicted[index]!; + const expected = page[1] - frontier[1], length = retainedValuesShape(item.values, maxEntries, seen); + if (item.principal !== target.principal || page[0] !== target.principal || frontier[0] !== target.principal || frontier[1] > page[1] || length !== expected) fail(); + return { principal: target.principal, raw: item.values, expected, page, frontier }; + }); + let pageTotal = 0; + for (const page of pages) { if (page[1] > admitted - pageTotal) fail(); pageTotal += page[1]; } + let retainedTotal = 0; + for (const item of retained) { if (item.expected > admitted - retainedTotal) fail(); retainedTotal += item.expected; } + const entries: Array> = [], retainedResults = new Set(), retainedReceipts = new Set(), retainedDecisions = new Set(), retainedActions = new Set(); + for (let index = 0; index < bindings.length; index += 1) { + const target = bindings[index]!, current = retained[index]!, page = current.page, frontier = current.frontier; denseArray(current.raw, current.expected); const values = list(current.raw, current.expected, seen, true); let previous = frontier[1]; const parsed: LedgerEntry[] = []; + if (values.length !== current.expected) fail(); for (const item of values) { + const entry = object(item, ["page", "result"], seen), itemResult = resultCopy(entry.result, seen), entryPage = entry.page; + if (!safe(entryPage, 1, admitted) || itemResult === undefined) fail(); const result = itemResult as WorldActionResult, number = entryPage as number; + if (number !== previous + 1 || number > page[1] || result.actor !== target.actor || result.identity.run_id !== target.run_id || result.identity.world_id !== target.world_id || result.identity.world_instance_id !== target.world_instance_id || result.identity.manifest_digest !== target.manifest_digest || retainedResults.has(result.result_id) || retainedReceipts.has(result.receipt_id) || retainedDecisions.has(result.decision_id) || retainedActions.has(result.action_sequence)) fail(); + previous = number; retainedResults.add(result.result_id); retainedReceipts.add(result.receipt_id); retainedDecisions.add(result.decision_id); retainedActions.add(result.action_sequence); + parsed.push(frozen({ page: number, result })); + } + entries.push(frozen({ principal: target.principal, values: frozen(parsed) })); + } + const resultIds = ids(source.result_ids, RESULT, admitted, seen), receiptIds = ids(source.receipt_ids, ACT, admitted, seen), decisionIds = ids(source.decision_ids, DECISION, admitted, seen), actionSequences = sequences(source.action_sequences, admitted, seen), effectWatermarks = watermarks(source.effect_watermarks, admitted, seen), lastResult = suffix(resultIds.at(-1) ?? "world-result-0", RESULT) ?? 0; + if (lastResult >= MAX_NEXT_COUNTER) fail(); + const expectedResult = lastResult + 1, expectedEffect = (effectWatermarks.at(-1) ?? 0) + 1, watermarkByResult = new Map(resultIds.map((id, index) => [id, effectWatermarks[index]! ])); + if (pageTotal !== admitted || source.previous_action !== (actionSequences.at(-1) ?? 0) || receiptIds.some((id, index) => suffix(id, ACT) !== actionSequences[index]) || ![...retainedResults].every((id) => resultIds.includes(id)) || ![...retainedReceipts].every((id) => receiptIds.includes(id)) || ![...retainedDecisions].every((id) => decisionIds.includes(id)) || ![...retainedActions].every((id) => actionSequences.includes(id)) || source.next_result !== expectedResult || source.next_effect !== expectedEffect || entries.some((entry) => entry.values.some(({ result }) => result.status === "applied" && result.caused_effect_ids.some((effect) => suffix(effect, EFFECT)! > watermarkByResult.get(result.result_id)!)))) fail(); + return frozen({ version: SNAPSHOT_VERSION, max_entries: maxEntries, max_principals: source.max_principals as number, issuer: source.issuer as string, secret: source.secret as string, bindings: frozen(bindings), entries: frozen(entries), pages: frozen(pages), evicted: frozen(evicted), result_ids: resultIds, receipt_ids: receiptIds, decision_ids: decisionIds, action_sequences: actionSequences, effect_watermarks: effectWatermarks, admitted, previous_action: source.previous_action as number, next_result: source.next_result as number, next_effect: source.next_effect as number }); + } catch { return fail(); } +}; +export const snapshotWorldActionResultStore = (ledger: WorldActionResultLedger): unknown => { const authority = readWorldActionResultLedger(ledger); if (authority === undefined || authority.hasLiveReservation()) fail(); return parseWorldActionResultLedgerSnapshot(authority!.exportState()); }; +export const restoreWorldActionResultStore = (ledger: WorldActionResultLedger, snapshot: unknown): void => { const authority = readWorldActionResultLedger(ledger); if (authority === undefined) fail(); authority!.importState(parseWorldActionResultLedgerSnapshot(snapshot)); }; diff --git a/src/world/actionResultProjection.test.ts b/src/world/actionResultProjection.test.ts new file mode 100644 index 0000000..4522aa1 --- /dev/null +++ b/src/world/actionResultProjection.test.ts @@ -0,0 +1,131 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createWorldActionResultLedger, readWorldActionResultLedger } from "./actionResultLedger.js"; +import { prepareWorldActionResults } from "./actionResultProjection.js"; + +const identity = Object.freeze({ run_id: "run", world_id: "world", world_instance_id: "instance", manifest_digest: `sha256:${"a".repeat(64)}`, state_version: 7 }); +const binding = Object.freeze({ principal: "p", actor: "world://world/entity/p", run_id: "run", world_id: "world", world_instance_id: "instance", manifest_digest: identity.manifest_digest }); +const blueIdentity = Object.freeze({ ...identity, manifest_digest: `sha256:${"b".repeat(64)}` }); +const blueBinding = Object.freeze({ ...binding, principal: "blue", actor: "world://world/entity/blue", manifest_digest: blueIdentity.manifest_digest }); +const action = (sequence: number, principal = "p", actionIdentity = identity, holder = principal === "p" ? binding.actor : blueBinding.actor) => Object.freeze({ receipt_id: `world-act-${sequence}`, decision_id: `decision-${String(sequence).padStart(12, "0")}`, principal, holder, affordance: "world://world/affordance/kick", target: holder, at_tick: 4, dynamics_sequence: sequence, mechanics_action: "kick", mechanics_actor: `object:${principal}`, mechanics_target: `object:${principal}`, lowered_input: Object.freeze({}), identity: { ...actionIdentity, state_version: 1 } }); +const terminal = (sequence: number, disposition: "applied" | "rejected_at_mechanics", code?: string) => Object.freeze({ disposition, receipt_id: `world-act-${sequence}`, decision_id: `decision-${String(sequence).padStart(12, "0")}`, sequence, apply_tick: 4, projection: "not_configured" as const, ...(code === undefined ? {} : { public_code: code }) }); +const event = (causes: number[], sequence = 99) => ({ cause_action_sequences: causes, kind: "impact", payload: { n: 1 }, source: "private", target: "private", event_sequence: sequence, provenance: "mechanical" as const, tick: 4 }); +const registry = { projectEffect: (_kind: string, _payload: unknown) => Object.freeze({ effect: "effect:impact", payload: {} }) }; +const reservation = (actions: readonly ReturnType[], effects = 256, codes: readonly string[] = [], bindings: readonly { readonly principal: string; readonly actor: string; readonly run_id: string; readonly world_id: string; readonly world_instance_id: string; readonly manifest_digest: string }[] = [binding]) => { const ledger = createWorldActionResultLedger(); const authority = readWorldActionResultLedger(ledger)!; authority.reserve({ bindings }); const batch = () => authority.reserveBatch({ actions: actions.map((item) => ({ principal: item.principal, receipt_id: item.receipt_id, decision_id: item.decision_id, action_sequence: item.dynamics_sequence, declared_rejection_codes: codes })), effect_capacity: effects }); return { ledger, batch: batch(), retry: batch }; }; + +test("projects ordered shared effects with ledger-issued ids and post-step identity", () => { + const pending = [action(99), action(100)], issued = reservation(pending); + const results = prepareWorldActionResults({ pending, terminals: [terminal(99, "applied"), terminal(100, "applied")], events: [event([99, 100]), event([100], 1000)], postMechanicsStateVersion: 5, registry: registry as never, reservation: issued.batch }); + assert.deepEqual(results.map((result) => result.result_id), ["world-result-1", "world-result-2"]); + assert.deepEqual((results[0] as { caused_effect_ids: readonly string[] }).caused_effect_ids, ["world-effect-1"]); + assert.deepEqual((results[1] as { caused_effect_ids: readonly string[] }).caused_effect_ids, ["world-effect-1", "world-effect-2"]); + assert.equal(results[0]!.identity.state_version, 5); assert.equal(issued.ledger.read("p", { version: "simfile.world-action-result-page-request.v1" }).results.length, 2); +}); + +test("keeps red and blue identities distinct in one globally ordered shared-event batch", () => { + const pending = [action(1, "p", identity), action(2, "blue", blueIdentity)], issued = reservation(pending, 1, [], [binding, blueBinding]); + const results = prepareWorldActionResults({ pending, terminals: [terminal(1, "applied"), terminal(2, "applied")], events: [event([1, 2])], postMechanicsStateVersion: 5, registry: registry as never, reservation: issued.batch }); + assert.deepEqual(results.map((result) => result.action_sequence), [1, 2]); + assert.equal(results[0]!.identity.manifest_digest, identity.manifest_digest); assert.equal(results[1]!.identity.manifest_digest, blueIdentity.manifest_digest); + assert.deepEqual(results.map((result) => result.identity.state_version), [5, 5]); + assert.deepEqual((results[0] as { caused_effect_ids: readonly string[] }).caused_effect_ids, ["world-effect-1"]); + assert.deepEqual((results[1] as { caused_effect_ids: readonly string[] }).caused_effect_ids, ["world-effect-1"]); +}); + +test("accepts cross-action event order while preserving each public result order", () => { + const pending = [action(1), action(2)], issued = reservation(pending, 2); + const results = prepareWorldActionResults({ pending, terminals: [terminal(1, "applied"), terminal(2, "applied")], events: [event([2], 1), event([1, 2], 2)], postMechanicsStateVersion: 5, registry: registry as never, reservation: issued.batch }); + assert.deepEqual((results[0] as { caused_effect_ids: readonly string[] }).caused_effect_ids, ["world-effect-2"]); + assert.deepEqual((results[1] as { caused_effect_ids: readonly string[] }).caused_effect_ids, ["world-effect-1", "world-effect-2"]); +}); + +test("aborts projection failures with no admission or counter gap and rejects current rejected causes", () => { + const pending = [action(1)], issued = reservation(pending, 1); + assert.throws(() => prepareWorldActionResults({ pending, terminals: [terminal(1, "applied")], events: [event([1])], postMechanicsStateVersion: 5, registry: { projectEffect: () => { throw new Error("undeclared"); } } as never, reservation: issued.batch }), /undeclared/u); + const results = prepareWorldActionResults({ pending: [action(1)], terminals: [terminal(1, "applied")], events: [], postMechanicsStateVersion: 5, registry: registry as never, reservation: issued.retry() }); + assert.equal(results[0]!.result_id, "world-result-1"); + const rejected = reservation([action(2)]); assert.throws(() => prepareWorldActionResults({ pending: [action(2)], terminals: [terminal(2, "rejected_at_mechanics", "blocked")], events: [event([2])], postMechanicsStateVersion: 5, registry: registry as never, reservation: rejected.batch }), /rejected/u); +}); + +test("publishes declared mechanics rejection and ignores prior or causeless events", () => { + const rejected = reservation([action(900_000)], 0, ["blocked"]); + const values = prepareWorldActionResults({ pending: [action(900_000)], terminals: [terminal(900_000, "rejected_at_mechanics", "blocked")], events: [], postMechanicsStateVersion: 5, registry: registry as never, reservation: rejected.batch }); + assert.equal(values[0]!.result_id, "world-result-1"); assert.equal(values[0]!.status, "rejected_at_mechanics"); + const applied = reservation([action(900_001)]); + const result = prepareWorldActionResults({ pending: [action(900_001)], terminals: [terminal(900_001, "applied")], events: [event([1]), event([]), event([900_001], 2)], postMechanicsStateVersion: 5, registry: registry as never, reservation: applied.batch }); + assert.deepEqual((result[0] as { caused_effect_ids: readonly string[] }).caused_effect_ids, ["world-effect-1"]); +}); + +test("detaches queued action identity before constructing results and rejects foreign identity", () => { + const originalManifest = identity.manifest_digest; + const mutable: { run_id: string; world_id: string; world_instance_id: string; manifest_digest: string; state_version: number } = { ...identity }; + const pending = [{ ...action(1), identity: mutable as ReturnType["identity"] }]; + const issued = reservation(pending); + assert.equal(mutable.manifest_digest, originalManifest); + const values = prepareWorldActionResults({ pending, terminals: [terminal(1, "applied")], events: [], postMechanicsStateVersion: 5, registry: registry as never, reservation: issued.batch }); + mutable.manifest_digest = `sha256:${"c".repeat(64)}`; + const stored = issued.ledger.read("p", { version: "simfile.world-action-result-page-request.v1" }).results[0]!; + assert.ok(Object.isFrozen(values[0]!.identity)); assert.ok(Object.isFrozen(stored.identity)); + assert.equal(values[0]!.identity.manifest_digest, originalManifest); assert.equal(stored.identity.manifest_digest, originalManifest); + assert.equal(values[0]!.identity.state_version, 5); assert.equal(stored.identity.state_version, 5); + const global = reservation([action(2)]); const withGlobalDigest = { pending: [action(2)], terminals: [terminal(2, "applied")], events: [], postMechanicsStateVersion: 5, identity: blueIdentity, registry: registry as never, reservation: global.batch }; + const globalResults = prepareWorldActionResults(withGlobalDigest as never); assert.equal(globalResults[0]!.identity.manifest_digest, identity.manifest_digest); +}); + +test("rejects every substituted queued-action identity field atomically", () => { + const substitutions = [ + ["run_id", "other-run"], ["world_id", "other-world"], ["world_instance_id", "other-instance"], + ["manifest_digest", `sha256:${"b".repeat(64)}`], + ] as const; + for (const [field, value] of substitutions) { + const substituted = { ...identity, [field]: value }; + const pending = [action(1, "p", substituted)], issued = reservation(pending); + assert.throws(() => prepareWorldActionResults({ pending, terminals: [terminal(1, "applied")], events: [], postMechanicsStateVersion: 5, registry: registry as never, reservation: issued.batch }), field); + assert.equal(issued.ledger.read("p", { version: "simfile.world-action-result-page-request.v1" }).results.length, 0); + const retry = issued.retry(); + assert.equal(retry.resultId(0), "world-result-1"); assert.equal(retry.effectId(0), "world-effect-1"); + retry.abort(); + } +}); + +test("rejects hostile queued-action identities without executing callbacks", () => { + type HostileCase = { readonly hostile: object; readonly value: number }; + const cases: readonly [string, () => HostileCase][] = [ + ["accessor", () => { let value = 0; const hostile = { ...identity }; Object.defineProperty(hostile, "run_id", { enumerable: true, get: () => { value += 1; return identity.run_id; } }); return { hostile, get value() { return value; } }; }], + ["proxy", () => { let value = 0; const target = { ...identity }; const hostile = new Proxy(target, { get: () => { value += 1; return identity.run_id; }, ownKeys: () => { value += 1; return Reflect.ownKeys(target); }, getOwnPropertyDescriptor: () => { value += 1; return undefined; } }); return { hostile, get value() { return value; } }; }], + ]; + for (const [name, make] of cases) { + const hostile = make(), pending = [{ ...action(1), identity: hostile.hostile as never }], issued = reservation(pending as never); + assert.throws(() => prepareWorldActionResults({ pending: pending as never, terminals: [terminal(1, "applied")], events: [], postMechanicsStateVersion: 5, registry: registry as never, reservation: issued.batch }), name); + assert.equal(hostile.value, 0); assert.equal(issued.ledger.read("p", { version: "simfile.world-action-result-page-request.v1" }).results.length, 0); + const retry = issued.retry(); + assert.equal(retry.resultId(0), "world-result-1"); assert.equal(retry.effectId(0), "world-effect-1"); + retry.abort(); + } +}); + +test("rejects unsafe post-state versions, tick mismatches, mixed ticks, and overflow atomically", () => { + const cases: readonly [string, number, readonly Record[]][] = [ + ["negative", -1, [terminal(1, "applied")]], ["fractional", 5.5, [terminal(1, "applied")]], + ["mismatch", 6, [terminal(1, "applied")]], ["mixed", 5, [terminal(1, "applied"), { ...terminal(2, "applied"), apply_tick: 3 }]], + ["unsafe-version", Number.MAX_SAFE_INTEGER + 1, [terminal(1, "applied")]], + ["overflow", Number.MAX_SAFE_INTEGER, [{ ...terminal(1, "applied"), apply_tick: Number.MAX_SAFE_INTEGER }],], + ]; + for (const [name, version, terminals] of cases) { + const pending = terminals.map((item) => action(item.sequence as number)), issued = reservation(pending); + assert.throws(() => prepareWorldActionResults({ pending, terminals: terminals as never, events: [], postMechanicsStateVersion: version, registry: registry as never, reservation: issued.batch }), name); + assert.equal(issued.ledger.read("p", { version: "simfile.world-action-result-page-request.v1" }).results.length, 0); + const retry = issued.retry(); + assert.equal(retry.resultId(0), "world-result-1"); + assert.equal(retry.effectId(0), "world-effect-1"); + retry.abort(); + } +}); + +test("batch admission owns declared rejection codes without blocking applications", () => { + const declared = reservation([action(1)], 0, ["blocked"]), invalid = { ...terminal(1, "rejected_at_mechanics", "undeclared"), public_code: "undeclared" }; + assert.throws(() => prepareWorldActionResults({ pending: [action(1)], terminals: [invalid], events: [], postMechanicsStateVersion: 5, registry: registry as never, reservation: declared.batch })); + const accepted = declared.retry(); prepareWorldActionResults({ pending: [action(1)], terminals: [terminal(1, "applied")], events: [], postMechanicsStateVersion: 5, registry: registry as never, reservation: accepted }); + assert.equal(declared.ledger.read("p", { version: "simfile.world-action-result-page-request.v1" }).results[0]!.status, "applied"); +}); diff --git a/src/world/actionResultProjection.ts b/src/world/actionResultProjection.ts new file mode 100644 index 0000000..9af5fa2 --- /dev/null +++ b/src/world/actionResultProjection.ts @@ -0,0 +1,53 @@ +import type { DynamicsEvent } from "../dynamics/types.js"; +import type { WorldSurfaceRegistry } from "../world-surface/index.js"; +import type { QueuedWorldAction, WorldActionTerminal } from "./actTypes.js"; +import { parseWorldActionResultIdentity, type WorldActionResult, type WorldActionResultIdentity } from "./actionResult.js"; +import type { WorldActionResultBatchReservation } from "./actionResultLedger.js"; + +export type WorldActionResultProjectionReservation = WorldActionResultBatchReservation; + +const frozen = (value: T): T => Object.freeze(value); +const checkedEvent = (event: DynamicsEvent): boolean => event.provenance === "mechanical" && Number.isSafeInteger(event.event_sequence) && event.event_sequence >= 0 && Number.isSafeInteger(event.tick) && event.tick >= 0 && Array.isArray(event.cause_action_sequences) && event.cause_action_sequences.every((value) => Number.isSafeInteger(value) && value > 0); + +/** @internal Builds public values only from an issued ledger batch reservation. */ +export const prepareWorldActionResults = (input: Readonly<{ + readonly pending: readonly QueuedWorldAction[]; readonly terminals: readonly WorldActionTerminal[]; readonly events: readonly DynamicsEvent[]; + readonly postMechanicsStateVersion: number; readonly registry: WorldSurfaceRegistry; readonly reservation: WorldActionResultBatchReservation; +}>): readonly WorldActionResult[] => { + try { + if (!Number.isSafeInteger(input.postMechanicsStateVersion) || input.postMechanicsStateVersion < 0) throw new Error("invalid post-mechanics state version"); + if (input.pending.length !== input.terminals.length) throw new Error("world action result terminal mismatch"); + const current = new Map(), accepted = new Set(), identities: WorldActionResultIdentity[] = []; + for (let index = 0; index < input.pending.length; index += 1) { + const action = input.pending[index]!, terminal = input.terminals[index]!; + const actionIdentity = parseWorldActionResultIdentity(action.identity); + if (terminal.sequence !== action.dynamics_sequence || terminal.receipt_id !== action.receipt_id || terminal.decision_id !== action.decision_id + || !Number.isSafeInteger(terminal.apply_tick) || terminal.apply_tick < 0 || terminal.apply_tick === Number.MAX_SAFE_INTEGER + || terminal.apply_tick + 1 !== input.postMechanicsStateVersion || actionIdentity === undefined || current.has(action.dynamics_sequence)) { + throw new Error("world action result terminal mismatch"); + } + const identity = parseWorldActionResultIdentity({ run_id: actionIdentity.run_id, world_id: actionIdentity.world_id, + world_instance_id: actionIdentity.world_instance_id, manifest_digest: actionIdentity.manifest_digest, + state_version: input.postMechanicsStateVersion }); + if (identity === undefined) throw new Error("invalid post-mechanics result identity"); + identities.push(identity); + current.set(action.dynamics_sequence, terminal); if (terminal.disposition === "applied") accepted.add(action.dynamics_sequence); + } + const effects = new Map(); let effectIndex = 0; + for (const event of input.events) { + if (!checkedEvent(event)) throw new Error("invalid checked world mechanics event"); + const currentCauses = event.cause_action_sequences.filter((sequence) => current.has(sequence)); + if (currentCauses.some((sequence) => !accepted.has(sequence))) throw new Error("rejected world action caused an event"); + const applicable = event.cause_action_sequences.filter((sequence) => accepted.has(sequence)); + if (applicable.length === 0) continue; + input.registry.projectEffect(event.kind, event.payload); + const id = input.reservation.effectId(effectIndex++); + for (const sequence of applicable) { const values = effects.get(sequence) ?? []; values.push(id); effects.set(sequence, values); } + } + const results = input.pending.map((action, index) => { + const terminal = input.terminals[index]!, base = { version: "simfile.world-action-result.v1" as const, result_id: input.reservation.resultId(index), receipt_id: action.receipt_id, decision_id: action.decision_id, actor: action.holder, action_sequence: action.dynamics_sequence, apply_tick: terminal.apply_tick, identity: identities[index]! }; + return terminal.disposition === "applied" ? frozen({ ...base, status: "applied" as const, caused_effect_ids: frozen(effects.get(action.dynamics_sequence) ?? []) }) : frozen({ ...base, status: "rejected_at_mechanics" as const, rejection_code: terminal.public_code ?? "world_action_rejected" }); + }); + input.reservation.publish(results); return frozen(results); + } catch (error) { try { input.reservation.abort(); } catch {} throw error; } +}; diff --git a/src/world/actionResults.test.ts b/src/world/actionResults.test.ts new file mode 100644 index 0000000..e673064 --- /dev/null +++ b/src/world/actionResults.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import type { DynamicsSession } from "../dynamics/session.js"; +import { createWorldActionJournal } from "./actionJournal.js"; +import { resolveWorldActionStep } from "./actionResults.js"; + +const identity = Object.freeze({ run_id: "run", world_id: "world", world_instance_id: "instance", manifest_digest: `sha256:${"a".repeat(64)}`, state_version: 0 }); +const add = (journal: ReturnType, sequence: number): void => { + const receipt = Object.freeze({ disposition: "queued" as const, receipt_id: `world-act-${sequence}`, decision_id: `decision-${String(sequence).padStart(12, "0")}`, identity, apply_tick: 0 }); + const record = Object.freeze({ receipt_id: receipt.receipt_id, decision_id: receipt.decision_id, principal: "p", holder: "world://world/entity/p", affordance: "world://world/affordance/kick", target: "world://world/entity/p", at_tick: 0, dynamics_sequence: sequence, mechanics_action: "kick", mechanics_actor: "object:p", mechanics_target: "object:p", lowered_input: Object.freeze({}), identity }); + journal.audit("p", "queued"); const cell = journal.reserve(receipt, sequence); cell.persist(record); cell.prepareAuthorization(); cell.authorize(); +}; +const setup = (count = 1, dynamicsOptions: { readonly largeSnapshot?: boolean; readonly snapshotThrows?: boolean; readonly restoreThrows?: boolean } = {}) => { + const journal = createWorldActionJournal(); journal.reservePrincipals(["p"]); + for (let sequence = 1; sequence <= count; sequence += 1) add(journal, sequence); + const state: Record = dynamicsOptions.largeSnapshot + ? { values: Array.from({ length: DYNAMICS_LIMITS.json_nodes + 1 }, () => ({ value: 0 })) } + : { value: 0 }; + let projects = 0; let closed = 0; let restores = 0; + // Direct resolver tests intentionally double only its snapshot/restore dependency. + const dynamics: Pick = { + snapshot: () => { + if (dynamicsOptions.snapshotThrows) throw new Error("snapshot"); + return structuredClone(state) as unknown as ReturnType; + }, + restore: (value) => { + if (dynamicsOptions.restoreThrows) throw new Error("restore"); + restores += 1; + Object.assign(state, value as typeof state); + }, + }; + const registry: { affordances: readonly { readonly address: string; readonly rejection_codes: readonly string[] }[]; projectAffordanceResult: () => unknown } = { + affordances: [{ address: "affordance:kick", rejection_codes: ["blocked"] }], projectAffordanceResult: () => { projects += 1; return { outcome: "ok" }; }, + }; + const resolve = (results: readonly Record[], options: { readonly reentered?: boolean; readonly project?: () => unknown; readonly resultReservation?: never } = {}) => { + if (options.project !== undefined) registry.projectAffordanceResult = () => { projects += 1; return options.project!(); }; + resolveWorldActionStep({ dynamics: dynamics as DynamicsSession, surfaceRegistry: registry as never, journal, reservation: journal.reserveTerminals(0), + step: { tick: 0, events: [], action_results: results as never }, reentered: () => options.reentered === true, closeMechanics: () => { closed += 1; } }); + }; + return { journal, resolve, projects: () => projects, closed: () => closed, restores: () => restores }; +}; +const result = (sequence: number, accepted = true, extra: Record = {}) => ({ accepted, sequence, act_id: `world-act-${sequence}`, action: "kick", actor: "object:p", principal_id: "p", target: "object:p", apply_tick: 0, origin: "agentic", ...extra }); + +test("commits every exact mechanical fact before its first accepted projection", () => { + const fixture = setup(2); + let beforeFirstProjection: unknown; + fixture.resolve([result(1), result(2)], { project: () => { + beforeFirstProjection ??= fixture.journal.snapshot().cells.map((cell) => cell.terminal?.disposition); + return { outcome: "ok" }; + } }); + assert.deepEqual(beforeFirstProjection, ["applied", "applied"]); + assert.deepEqual(fixture.journal.snapshot().cells.map((cell) => [cell.terminal?.disposition, cell.terminal?.projection]), [["applied", "projected"], ["applied", "projected"]]); + assert.equal(fixture.projects(), 2); +}); + +test("projects valid issued snapshots above the hostile JSON node ceiling", () => { + const fixture = setup(1, { largeSnapshot: true }); + fixture.resolve([result(1)]); + assert.equal(fixture.journal.snapshot().cells[0]!.terminal?.projection, "projected"); + assert.equal(fixture.projects(), 1); + assert.equal(fixture.restores(), 0); +}); + +test("commits journal terminals before batch result publication and aborts no impossible join", () => { + const fixture = setup(); let published = 0, aborted = 0, terminals: unknown; + const reservation = Object.freeze({ resultId: () => "world-result-1", effectId: () => { throw new Error("no effects"); }, publish: () => { published += 1; terminals = fixture.journal.snapshot().cells[0]!.terminal?.disposition; }, abort: () => { aborted += 1; } }); + resolveWorldActionStep({ dynamics: { snapshot: () => ({}), restore: () => {} } as never, surfaceRegistry: { affordances: [{ address: "affordance:kick", rejection_codes: [] }], projectEffect: () => ({}) } as never, journal: fixture.journal, reservation: fixture.journal.reserveTerminals(0), step: { tick: 0, events: [], action_results: [result(1)] } as never, reentered: () => false, closeMechanics: () => {}, resultReservation: reservation, postMechanicsStateVersion: 1 }); + assert.equal(published, 1); assert.equal(terminals, "applied"); assert.equal(aborted, 0); + const broken = setup(); const never = Object.freeze({ ...reservation, publish: () => { published += 100; }, abort: () => { aborted += 1; } }); + assert.throws(() => resolveWorldActionStep({ dynamics: {} as never, surfaceRegistry: {} as never, journal: broken.journal, reservation: broken.journal.reserveTerminals(0), step: { tick: 0, events: [], action_results: [] } as never, reentered: () => false, closeMechanics: () => {}, resultReservation: never, postMechanicsStateVersion: 1 })); + assert.equal(aborted, 1); assert.equal(published, 1); +}); + +test("closes and aborts exactly once for either one-sided result configuration", () => { + for (const withReservation of [false, true]) { + let aborted = 0; const fixture = setup(); const configuration = withReservation + ? { resultReservation: Object.freeze({ resultId: () => "world-result-1", effectId: () => "world-effect-1", publish: () => { throw new Error("publish"); }, abort: () => { aborted += 1; } }), postMechanicsStateVersion: undefined } + : { resultReservation: undefined, postMechanicsStateVersion: 1 }; + assert.throws(() => resolveWorldActionStep({ dynamics: {} as never, surfaceRegistry: {} as never, journal: fixture.journal, reservation: fixture.journal.reserveTerminals(0), step: { tick: 0, events: [], action_results: [result(1)] } as never, reentered: () => false, closeMechanics: () => {}, ...configuration })); + assert.equal(fixture.journal.snapshot().closed, true); assert.equal(fixture.journal.snapshot().cells[0]!.terminal, null); assert.equal(aborted, withReservation ? 1 : 0); + } +}); + +test("keeps rejected mechanics terminal and exposes only declared codes", () => { + const declared = setup(); declared.resolve([result(1, false, { code: "blocked", message: "host-only" })]); + assert.deepEqual(declared.journal.snapshot().cells[0]!.terminal, { disposition: "rejected_at_mechanics", receipt_id: "world-act-1", decision_id: "decision-000000000001", sequence: 1, apply_tick: 0, projection: "not_configured", public_code: "blocked" }); + assert.equal(declared.projects(), 0); + const hidden = setup(); hidden.resolve([result(1, false, { code: "provider-secret", message: "host-only" })]); + assert.equal(hidden.journal.snapshot().cells[0]!.terminal?.public_code, "world_action_rejected"); + assert.equal(hidden.projects(), 0); +}); + +test("closes every impossible join with authorized pending evidence and no terminal facts", () => { + const invalidResults: ReadonlyArray[]]> = [ + ["missing", [result(1)]], + ["duplicate", [result(1), result(1)]], + ["foreign", [result(1), result(99)]], + ["sequence", [result(1), result(2, true, { sequence: 99 })]], + ["act_id", [result(1), result(2, true, { act_id: "world-act-1" })]], + ["action", [result(1), result(2, true, { action: "wrong" })]], + ["actor", [result(1), result(2, true, { actor: "object:wrong" })]], + ["principal", [result(1), result(2, true, { principal_id: "wrong" })]], + ["target", [result(1), result(2, true, { target: "wrong" })]], + ["apply_tick", [result(1), result(2, true, { apply_tick: 1 })]], + ]; + for (const [name, results] of invalidResults) for (const resultMode of [false, true]) { + const fixture = setup(2); let aborted = 0, published = 0; + const resultReservation = Object.freeze({ resultId: () => "world-result-1", effectId: () => "world-effect-1", publish: () => { published += 1; }, abort: () => { aborted += 1; } }); + assert.throws(() => resolveWorldActionStep({ dynamics: {} as never, surfaceRegistry: {} as never, journal: fixture.journal, reservation: fixture.journal.reserveTerminals(0), step: { tick: 0, events: [], action_results: results } as never, reentered: () => false, closeMechanics: () => {}, ...(resultMode ? { resultReservation, postMechanicsStateVersion: 1 } : {}) }), `${name}/${resultMode ? "result" : "legacy"}`); + assert.throws(() => fixture.journal.reserveAudit("p")); + const snapshot = fixture.journal.snapshot(); + assert.equal(snapshot.closed, true); assert.equal(aborted, resultMode ? 1 : 0); assert.equal(published, 0); + assert.deepEqual(snapshot.cells.map((cell) => [cell.state, cell.terminal]), [["authorized", null], ["authorized", null]]); + } +}); + +test("surfaces a result-reservation abort failure after closing a malformed join", () => { + const fixture = setup(); let aborts = 0; let publishes = 0; + const settlement = new Error("result settlement failed"); + const reservation = Object.freeze({ resultId: () => "world-result-1", effectId: () => "world-effect-1", + publish: () => { publishes += 1; }, abort: () => { aborts += 1; throw settlement; } }); + assert.throws(() => resolveWorldActionStep({ dynamics: {} as never, surfaceRegistry: {} as never, + journal: fixture.journal, reservation: fixture.journal.reserveTerminals(0), + step: { tick: 0, events: [], action_results: [] } as never, reentered: () => false, closeMechanics: () => {}, + resultReservation: reservation, postMechanicsStateVersion: 1 }), (error) => error === settlement); + assert.equal(aborts, 1); assert.equal(publishes, 0); assert.equal(fixture.journal.snapshot().closed, true); +}); + +test("projection failures retain applied mechanics and restore the post-step snapshot", () => { + const fixture = setup(); + fixture.resolve([result(1)], { project: () => { throw new Error("projection"); } }); + assert.equal(fixture.journal.snapshot().cells[0]!.terminal?.disposition, "applied"); + assert.equal(fixture.journal.snapshot().cells[0]!.terminal?.projection, "failed"); + const reentered = setup(); reentered.resolve([result(1)], { reentered: true }); + assert.equal(reentered.projects(), 0); + assert.equal(reentered.journal.snapshot().cells[0]!.terminal?.projection, "failed"); +}); + +test("post-step snapshot failure closes mechanics and fails the current clock work", () => { + const fixture = setup(1, { snapshotThrows: true }); + assert.throws(() => fixture.resolve([result(1)]), /post-step snapshot/u); + assert.equal(fixture.closed(), 1); + assert.equal(fixture.projects(), 0); + const snapshot = fixture.journal.snapshot(); + assert.equal(snapshot.closed, true); + assert.equal(snapshot.cells[0]!.terminal?.disposition, "applied"); + assert.equal(snapshot.cells[0]!.terminal?.projection, "not_configured"); +}); + +test("restore failure closes mechanics and stops later projection callbacks", () => { + const fixture = setup(2, { restoreThrows: true }); + assert.throws(() => fixture.resolve([result(1), result(2)], { project: () => { throw new Error("projection"); } }), /restore/u); + assert.equal(fixture.projects(), 1); + assert.equal(fixture.closed(), 1); + const snapshot = fixture.journal.snapshot(); + assert.equal(snapshot.closed, true); + assert.deepEqual(snapshot.cells.map((cell) => [cell.terminal?.disposition, cell.terminal?.projection]), [["applied", "not_configured"], ["applied", "not_configured"]]); +}); diff --git a/src/world/actionResults.ts b/src/world/actionResults.ts new file mode 100644 index 0000000..390bfa3 --- /dev/null +++ b/src/world/actionResults.ts @@ -0,0 +1,115 @@ +import type { DynamicsSession } from "../dynamics/session.js"; +import { sameDynamicsSessionSnapshot } from "../dynamics/sameDynamicsSessionSnapshot.js"; +import type { DynamicsActionResult, DynamicsStepResult } from "../dynamics/types.js"; +import type { WorldSurfaceRegistry } from "../world-surface/index.js"; +import type { WorldActionJournal, WorldActionTerminalReservation } from "./actionJournal.js"; +import { parseWorldActionTerminal } from "./actionJournalSnapshot.js"; +import type { QueuedWorldAction, WorldActionTerminal, WorldActQueuedReceipt } from "./actTypes.js"; +import { prepareWorldActionResults, type WorldActionResultProjectionReservation } from "./actionResultProjection.js"; + +const localAffordance = (address: string): string => { + const parts = address.split("/"); + if (parts.length < 2 || parts.at(-2) !== "affordance") throw new Error("invalid queued affordance"); + return `affordance:${parts.at(-1)!}`; +}; +const terminal = (action: QueuedWorldAction, result: DynamicsActionResult, code?: string): WorldActionTerminal => Object.freeze({ + disposition: result.accepted ? "applied" : "rejected_at_mechanics", receipt_id: action.receipt_id, decision_id: action.decision_id, + sequence: action.dynamics_sequence, apply_tick: action.at_tick, projection: "not_configured", + ...(result.accepted ? {} : { public_code: code }), +}); +const queuedReceipt = (action: QueuedWorldAction): WorldActQueuedReceipt => Object.freeze({ + disposition: "queued", receipt_id: action.receipt_id, decision_id: action.decision_id, + identity: action.identity, apply_tick: action.at_tick, +}); +const exact = (action: QueuedWorldAction, result: DynamicsActionResult): boolean => + result.sequence === action.dynamics_sequence && result.act_id === action.receipt_id && result.action === action.mechanics_action + && result.actor === action.mechanics_actor && result.principal_id === action.principal && result.target === action.mechanics_target + && result.apply_tick === action.at_tick; +const publicRejectionCode = (action: QueuedWorldAction, result: DynamicsActionResult, registry: WorldSurfaceRegistry): string => { + const local = localAffordance(action.affordance); + const declared = registry.affordances.find((entry) => entry.address === local); + return typeof result.code === "string" && declared?.rejection_codes.includes(result.code) ? result.code : "world_action_rejected"; +}; +const failProjection = (journal: WorldActionJournal, fact: WorldActionTerminal): void => { + journal.project(Object.freeze({ ...fact, projection: "failed" })); +}; +const closeJoin = (journal: WorldActionJournal, message: string, resultReservation?: WorldActionResultProjectionReservation): never => { + let settlementError: unknown; + try { resultReservation?.abort(); } catch (error) { settlementError = error; } + finally { journal.close(); } + if (settlementError !== undefined) throw settlementError; + throw new Error(message); +}; +const closeOperationally = (input: Readonly<{ journal: WorldActionJournal; closeMechanics: () => void }>, message: string): never => { + input.journal.close(); + input.closeMechanics(); + throw new Error(message); +}; + +/** @internal joins one already-reserved host step result before invoking projections. */ +export const resolveWorldActionStep = (input: Readonly<{ + dynamics: DynamicsSession; surfaceRegistry: WorldSurfaceRegistry; journal: WorldActionJournal; + reservation: WorldActionTerminalReservation; step: DynamicsStepResult; reentered: () => boolean; closeMechanics: () => void; + resultReservation?: WorldActionResultProjectionReservation; postMechanicsStateVersion?: number; +}>): void => { + const pending = input.reservation.queued; + if ((input.resultReservation === undefined) !== (input.postMechanicsStateVersion === undefined)) { + return closeJoin(input.journal, "world action result projection configuration", input.resultReservation); + } + if (!Array.isArray(input.step.action_results) || input.step.action_results.length !== pending.length) { + return closeJoin(input.journal, "world action mechanics result mismatch", input.resultReservation); + } + const expected = new Map(pending.map((action) => [action.dynamics_sequence, action])); + const bySequence = new Map(); + for (const result of input.step.action_results) { + if (result === null || typeof result !== "object" || !expected.has(result.sequence)) { + return closeJoin(input.journal, "foreign world action mechanics result", input.resultReservation); + } + if (bySequence.has(result.sequence)) return closeJoin(input.journal, "duplicate world action mechanics result", input.resultReservation); + const action = expected.get(result.sequence)!; + if (!exact(action, result)) return closeJoin(input.journal, "world action mechanics result mismatch", input.resultReservation); + bySequence.set(result.sequence, result); + } + const resolved: Array> = []; + const facts: WorldActionTerminal[] = []; + for (const action of pending) { + const result = bySequence.get(action.dynamics_sequence); + if (result === undefined) return closeJoin(input.journal, "world action mechanics result mismatch", input.resultReservation); + resolved.push({ action, result }); + facts.push(terminal(action, result, result.accepted ? undefined : publicRejectionCode(action, result, input.surfaceRegistry))); + } + input.reservation.commit(facts); + if (input.resultReservation !== undefined && input.postMechanicsStateVersion !== undefined) { + prepareWorldActionResults({ pending, terminals: facts, events: input.step.events, postMechanicsStateVersion: input.postMechanicsStateVersion, registry: input.surfaceRegistry, reservation: input.resultReservation }); + } + if (facts.every((fact) => fact.disposition !== "applied")) return; + let postStep: ReturnType; + try { postStep = input.dynamics.snapshot(); } + catch { return closeOperationally(input, "world action post-step snapshot failed"); } + for (let index = 0; index < facts.length; index += 1) { + const fact = facts[index]!; + if (fact.disposition !== "applied") continue; + if (input.reentered()) { + try { failProjection(input.journal, fact); } + catch { return closeOperationally(input, "world action projection failure could not be recorded"); } + continue; + } + try { + const effect = input.surfaceRegistry.projectAffordanceResult(localAffordance(resolved[index]!.action.affordance) as never, Object.freeze({ accepted: true })); + if (input.reentered() + || !sameDynamicsSessionSnapshot(postStep, input.dynamics.snapshot())) { + throw new Error("world action projection failed"); + } + const candidate = Object.freeze({ ...fact, projection: effect === undefined ? "not_configured" : "projected", ...(effect === undefined ? {} : { effect }) }); + if (parseWorldActionTerminal(candidate, queuedReceipt(resolved[index]!.action), fact.sequence) === undefined) { + throw new Error("world action projection failed"); + } + input.journal.project(candidate); + } catch { + try { input.dynamics.restore(postStep); } + catch { return closeOperationally(input, "world action projection restore failed"); } + try { failProjection(input.journal, fact); } + catch { return closeOperationally(input, "world action projection failure could not be recorded"); } + } + } +}; diff --git a/src/world/addresses.test.ts b/src/world/addresses.test.ts new file mode 100644 index 0000000..3a26bdd --- /dev/null +++ b/src/world/addresses.test.ts @@ -0,0 +1,103 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { + createWorldPath, + parseLocalResourceReference, + parseWorldId, + resolveWorldAddress, + resolveWorldResourceRegistry, + type ResolvedWorldResourceEntry, +} from "./addresses.js"; + +test("resolves authored local references to exact canonical addresses", () => { + const world = { id: parseWorldId("pitch") }; + const reference = parseLocalResourceReference("affordance:kick"); + + assert.equal(resolveWorldAddress(world, reference), "world://pitch/affordance/kick"); +}); + +test("preserves an explicit ordered ancestor path around a singular world id", () => { + const world = { id: parseWorldId("match-one"), ancestors: createWorldPath("league") }; + + assert.equal( + resolveWorldAddress(world, parseLocalResourceReference("sense:player-view")), + "world://league/match-one/sense/player-view", + ); +}); + +test("parses only singular portable world ids and revalidates scopes at resolution", () => { + assert.equal(parseWorldId("pitch"), "pitch"); + for (const value of ["league/pitch", "Pitch", " pitch", "pitch ", "pitch--one", 1, null, {}]) { + assert.throws(() => parseWorldId(value), /world id must be one portable lowercase kebab-case segment/); + } + + const reference = parseLocalResourceReference("entity:red"); + assert.throws(() => resolveWorldAddress({ id: "pitch/other" } as never, reference), /world id/); + assert.throws(() => resolveWorldAddress({ id: "pitch", ancestors: "league" } as never, reference), /ancestor path/); + assert.throws(() => resolveWorldAddress({ id: "pitch", ancestors: ["League"] } as never, reference), /world id/); +}); + +test("rejects invalid kinds, ids, paths, and non-string local references without normalization", () => { + for (const value of [ + "object:ball", + "entity:", + "entity:Red", + "entity:red_blue", + "entity:red--blue", + "entity:.red", + "entity:red/blue", + "entity:red\\blue", + "entity:red space", + "entity:red:blue", + 1, + null, + {}, + ["entity:red"], + ]) { + assert.throws(() => parseLocalResourceReference(value), /local resource reference/); + } + + assert.throws(() => createWorldPath("league", "Match-one"), /world id/); + assert.throws(() => createWorldPath("league/match-one"), /world id/); + assert.throws(() => createWorldPath("."), /world id/); +}); + +test("rejects hostile URI, encoded, absolute, query, and fragment forms", () => { + for (const value of [ + "world://pitch/entity/red", + "https://example.test/entity/red", + "entity:red%2fblue", + "entity:red%3Ablue", + "entity:red?next=blue", + "entity:red#blue", + "entity:red\u0000blue", + ]) { + assert.throws(() => parseLocalResourceReference(value), /Invalid local resource reference/); + } +}); + +test("detects duplicate canonical addresses in one world deterministically", () => { + const world = { id: parseWorldId("pitch") }; + const red = parseLocalResourceReference("entity:red"); + + assert.throws( + () => resolveWorldResourceRegistry(world, [red, red]), + /Canonical world address collision: world:\/\/pitch\/entity\/red/, + ); +}); + +test("returns a registry callers cannot mutate and isolates identical local references", () => { + const ball = parseLocalResourceReference("entity:ball"); + const first = resolveWorldResourceRegistry({ id: parseWorldId("pitch-one") }, [ball]); + const second = resolveWorldResourceRegistry({ id: parseWorldId("pitch-two") }, [ball]); + + assert.equal(first.entries.length, 1); + assert.equal(second.entries.length, 1); + assert.notEqual(first.entries[0]?.address, second.entries[0]?.address); + assert.throws(() => (first.entries as ResolvedWorldResourceEntry[]).push(first.entries[0]!), TypeError); + assert.throws(() => { + (first.entries[0] as { reference: string }).reference = "entity:blue"; + }, TypeError); + assert.equal(first.entries[0]?.reference, ball); +}); diff --git a/src/world/addresses.ts b/src/world/addresses.ts new file mode 100644 index 0000000..58d7779 --- /dev/null +++ b/src/world/addresses.ts @@ -0,0 +1,128 @@ +const RESOURCE_KINDS = ["entity", "sense", "affordance", "effect"] as const; +const PORTABLE_SEGMENT = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/; + +declare const localReferenceBrand: unique symbol; +declare const worldIdBrand: unique symbol; +declare const worldPathBrand: unique symbol; +declare const canonicalAddressBrand: unique symbol; + +export type WorldResourceKind = (typeof RESOURCE_KINDS)[number]; + +export type LocalResourceReference = string & { + readonly [localReferenceBrand]: "LocalResourceReference"; +}; + +export type WorldId = string & { + readonly [worldIdBrand]: "WorldId"; +}; + +export type WorldPath = readonly WorldId[] & { + readonly [worldPathBrand]: "WorldPath"; +}; + +export type CanonicalWorldAddress = string & { + readonly [canonicalAddressBrand]: "CanonicalWorldAddress"; +}; + +export interface WorldAddressScope { + readonly id: WorldId; + readonly ancestors?: WorldPath; +} + +export interface ResolvedWorldResourceEntry { + readonly address: CanonicalWorldAddress; + readonly reference: LocalResourceReference; +} + +export interface ResolvedWorldResourceRegistry { + readonly world: WorldPath; + readonly entries: readonly ResolvedWorldResourceEntry[]; +} + +export const parseLocalResourceReference = (input: unknown): LocalResourceReference => { + if (typeof input !== "string") { + throw new TypeError("A local resource reference must be a string."); + } + + const separator = input.indexOf(":"); + if (separator <= 0 || separator !== input.lastIndexOf(":")) { + throw new TypeError(`Invalid local resource reference: ${JSON.stringify(input)}.`); + } + + const kind = input.slice(0, separator); + const localId = input.slice(separator + 1); + if (!isWorldResourceKind(kind) || !isPortableSegment(localId)) { + throw new TypeError(`Invalid local resource reference: ${JSON.stringify(input)}.`); + } + + return input as LocalResourceReference; +}; + +export const parseWorldId = (input: unknown): WorldId => { + if (!isPortableSegment(input)) { + throw new TypeError("A world id must be one portable lowercase kebab-case segment."); + } + + return input as WorldId; +}; + +export const createWorldPath = (...segments: readonly unknown[]): WorldPath => { + if (segments.length === 0) { + throw new TypeError("A world path must contain nonempty portable lowercase kebab-case segments."); + } + + return Object.freeze(segments.map(parseWorldId)) as unknown as WorldPath; +}; + +export const resolveWorldAddress = ( + world: WorldAddressScope, + reference: LocalResourceReference, +): CanonicalWorldAddress => { + const path = resolvedWorldPath(world); + const localReference = parseLocalResourceReference(reference); + const [kind, localId] = localReference.split(":") as [WorldResourceKind, string]; + return `world://${path.join("/")}/${kind}/${localId}` as CanonicalWorldAddress; +}; + +export const resolveWorldResourceRegistry = ( + world: WorldAddressScope, + references: readonly LocalResourceReference[], +): ResolvedWorldResourceRegistry => { + const path = resolvedWorldPath(world); + const entries: ResolvedWorldResourceEntry[] = []; + const seen = new Set(); + + for (const reference of references) { + const canonicalAddress = resolveWorldAddress(world, reference); + if (seen.has(canonicalAddress)) { + throw new TypeError(`Canonical world address collision: ${canonicalAddress}.`); + } + seen.add(canonicalAddress); + entries.push(Object.freeze({ address: canonicalAddress, reference: parseLocalResourceReference(reference) })); + } + + return Object.freeze({ world: path, entries: Object.freeze(entries) }); +}; + +export const isWorldResourceKind = (input: unknown): input is WorldResourceKind => + typeof input === "string" && (RESOURCE_KINDS as readonly string[]).includes(input); + +const isPortableSegment = (input: unknown): input is string => + typeof input === "string" && PORTABLE_SEGMENT.test(input); + +const resolvedWorldPath = (world: WorldAddressScope): WorldPath => { + if (!world || typeof world !== "object") { + throw new TypeError("Resolution requires a world scope."); + } + + const { id, ancestors } = world; + const worldId = parseWorldId(id); + if (ancestors === undefined) { + return createWorldPath(worldId); + } + if (!Array.isArray(ancestors)) { + throw new TypeError("A world ancestor path must be an ordered array of portable segments."); + } + + return createWorldPath(...ancestors, worldId); +}; diff --git a/src/world/affordances-hostile.test.ts b/src/world/affordances-hostile.test.ts new file mode 100644 index 0000000..76e520c --- /dev/null +++ b/src/world/affordances-hostile.test.ts @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DynamicsSession } from "../dynamics/session.js"; +import { WorldRuntimeError } from "./ledger.js"; +import { runtimeFixtureWithHooks } from "./runtime.test-helper.js"; + +const denied = (call: () => unknown): void => assert.throws(call, (error: unknown) => + error instanceof WorldRuntimeError && error.code === "world_runtime_denied"); +const action = (id: string) => ({ act_id: id, action: "wait", actor: "object:red", at_tick: 0, + input: {}, origin: "controller", principal_id: "principal-red", target: "object:red" }); +const valid = (input: unknown) => ({ channels: [{ components: { x: 1 }, sense_address: (input as { sense_addresses: readonly string[] }).sense_addresses[0]!, subject_address: "object:red" }] }); +const context = (fixture: ReturnType) => + ({ principal: "principal-red", decisionToken: fixture.red.token }); +const audit = (fixture: ReturnType, result: "allowed" | "denied") => + assert.deepEqual(fixture.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["affordances", result]]); + +test("provider rollback is owned once and a denied runtime remains reusable", () => { + const cases: readonly [string, (input: unknown, state: Record, dynamics: DynamicsSession) => unknown, number][] = [ + ["throw", () => { throw new Error("provider"); }, 1], + ["promise", () => Promise.resolve(valid({ sense_addresses: ["sense:state"] })), 1], + ["malformed", () => ({ nope: true }), 1], + ["provider mutation", (_input, state) => { state.value = 1; return valid({ sense_addresses: ["sense:state"] }); }, 1], + ["queued host mutation", (_input, _state, dynamics) => { dynamics.queueAction(action("provider-queue")); return valid({ sense_addresses: ["sense:state"] }); }, 1], + ["stepped host mutation", (_input, _state, dynamics) => { dynamics.step(); return valid({ sense_addresses: ["sense:state"] }); }, 1], + ["provider mutation + throw", (_input, state) => { state.value = 1; throw new Error("provider"); }, 1], + ["provider mutation + promise", (_input, state) => { state.value = 1; return Promise.resolve(valid({ sense_addresses: ["sense:state"] })); }, 1], + ["provider mutation + malformed", (_input, state) => { state.value = 1; return { nope: true }; }, 1], + ["queued host mutation + throw", (_input, _state, dynamics) => { dynamics.queueAction(action("provider-queue-throw")); throw new Error("provider"); }, 2], + ["queued host mutation + promise", (_input, _state, dynamics) => { dynamics.queueAction(action("provider-queue-promise")); return Promise.resolve(valid({ sense_addresses: ["sense:state"] })); }, 2], + ["queued host mutation + malformed", (_input, _state, dynamics) => { dynamics.queueAction(action("provider-queue-malformed")); return { nope: true }; }, 2], + ["stepped host mutation + throw", (_input, _state, dynamics) => { dynamics.step(); throw new Error("provider"); }, 2], + ["stepped host mutation + promise", (_input, _state, dynamics) => { dynamics.step(); return Promise.resolve(valid({ sense_addresses: ["sense:state"] })); }, 2], + ["stepped host mutation + malformed", (_input, _state, dynamics) => { dynamics.step(); return { nope: true }; }, 2], + ]; + for (const [_name, hostile, restores] of cases) { + let enabled = true; + let providers = 0; let projections = 0; let availability = 0; + const fixture = runtimeFixtureWithHooks({ + observe: (input, state, dynamics) => { providers += 1; return enabled ? hostile(input, state, dynamics) : valid(input); }, + project: (input) => { projections += 1; return { channels: input.observation.channels.map((channel) => ({ ...channel, sense_address: "sense:vision", subject_address: "entity:red" })) }; }, + available: () => { availability += 1; return true; }, + }, true, { runId: "run-1", worldInstanceId: "instance-1", redSenses: ["world://pitch/sense/vision"], redAffordances: ["world://pitch/affordance/wait"] }); + const before = fixture.dynamics.snapshot(); denied(() => fixture.runtime!.affordances(context(fixture))); + assert.deepEqual(fixture.dynamics.snapshot(), before); assert.equal(fixture.restoreCalls(), restores, _name); audit(fixture, "denied"); + enabled = false; fixture.runtime!.affordances(context(fixture)); + assert.deepEqual([providers, projections, availability], [2, 1, 1]); + } +}); + +test("projection rollback restores only mutation and remains reusable", () => { + const cases: readonly [string, (dynamics: DynamicsSession) => unknown, number][] = [ + ["throw", () => { throw new Error("projection"); }, 0], ["promise", () => Promise.resolve({ channels: [] }), 0], ["malformed", () => ({ nope: true }), 0], + ["mutation valid", (dynamics) => { dynamics.step(); return { channels: [] }; }, 1], + ["mutation throw", (dynamics) => { dynamics.step(); throw new Error("projection"); }, 1], + ["mutation promise", (dynamics) => { dynamics.step(); return Promise.resolve({ channels: [] }); }, 1], + ["mutation malformed", (dynamics) => { dynamics.step(); return {}; }, 1], + ]; + for (const [_name, hostile, restores] of cases) { + let enabled = true; let providers = 0; let projections = 0; let availability = 0; + const fixture = runtimeFixtureWithHooks({ observe: (input) => { providers += 1; return valid(input); }, project: (input, dynamics) => { + projections += 1; return enabled ? hostile(dynamics) : { channels: input.observation.channels.map((channel) => ({ ...channel, sense_address: "sense:vision", subject_address: "entity:red" })) }; + }, available: () => { availability += 1; return true; } }, true, { runId: "run-1", worldInstanceId: "instance-1", redSenses: ["world://pitch/sense/vision"], redAffordances: ["world://pitch/affordance/wait"] }); + const before = fixture.dynamics.snapshot(); denied(() => fixture.runtime!.affordances(context(fixture))); + assert.deepEqual(fixture.dynamics.snapshot(), before); assert.equal(fixture.restoreCalls(), restores, _name); audit(fixture, "denied"); + enabled = false; fixture.runtime!.affordances(context(fixture)); assert.deepEqual([providers, projections, availability], [2, 2, 1]); + } +}); + +test("availability pure failures avoid restore while mutations restore once", () => { + const cases: readonly [string, (dynamics: DynamicsSession) => unknown, number][] = [ + ["throw", () => { throw new Error("available"); }, 0], ["promise", () => Promise.resolve(true), 0], ["malformed", () => "true", 0], + ...([true, false, "throw", "promise", "malformed"] as const).map((outcome): [string, (dynamics: DynamicsSession) => unknown, number] => [ + `mutation ${outcome}`, (dynamics) => { dynamics.step(); if (outcome === "throw") throw new Error("available"); if (outcome === "promise") return Promise.resolve(true); if (outcome === "malformed") return {}; return outcome; }, 1, + ]), + ...([true, false, "throw", "promise", "malformed"] as const).map((outcome): [string, (dynamics: DynamicsSession) => unknown, number] => [ + `queue mutation ${outcome}`, (dynamics) => { dynamics.queueAction(action(`available-queue-${outcome}`)); if (outcome === "throw") throw new Error("available"); if (outcome === "promise") return Promise.resolve(true); if (outcome === "malformed") return {}; return outcome; }, 1, + ]), + ["step mutation", (dynamics) => { dynamics.step(); return true; }, 1], + ]; + for (const [_name, hostile, restores] of cases) { + let enabled = true; let providers = 0; let projections = 0; let availability = 0; + const fixture = runtimeFixtureWithHooks({ observe: (input) => { providers += 1; return valid(input); }, project: (input) => { projections += 1; return { channels: input.observation.channels.map((channel) => ({ ...channel, sense_address: "sense:vision", subject_address: "entity:red" })) }; }, available: (_input, dynamics) => { availability += 1; return enabled ? hostile(dynamics) : true; } }, true, { runId: "run-1", worldInstanceId: "instance-1", redSenses: ["world://pitch/sense/vision"], redAffordances: ["world://pitch/affordance/wait"] }); + const before = fixture.dynamics.snapshot(); denied(() => fixture.runtime!.affordances(context(fixture))); + assert.deepEqual(fixture.dynamics.snapshot(), before); assert.equal(fixture.restoreCalls(), restores, _name); audit(fixture, "denied"); + enabled = false; fixture.runtime!.affordances(context(fixture)); assert.deepEqual([providers, projections, availability], [2, 2, 2]); + } +}); + +test("terminal and identity denials invoke no callbacks and audit the supplied principal", () => { + const cases = [ + (fixture: ReturnType) => ({ principal: "principal-red", decisionToken: fixture.blue.token }), + (fixture: ReturnType) => ({ principal: "unknown", decisionToken: fixture.red.token }), + (fixture: ReturnType) => { fixture.dynamics.step(); fixture.dynamics.step(); fixture.dynamics.step(); return context(fixture); }, + (fixture: ReturnType) => { fixture.decisionRegistry.consumeForAct({ principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: fixture.red.token, atTick: 0 }); return context(fixture); }, + (fixture: ReturnType) => { fixture.decisionRegistry.consumeForAct({ principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: fixture.red.token, atTick: 0 }); fixture.decisionRegistry.consumeForAct({ principal: "principal-blue", runId: "run-1", worldInstanceId: "instance-1", token: fixture.blue.token, atTick: 0 }); fixture.decisionRegistry.beginCutoff(0); fixture.decisionRegistry.closeAdmissions(0); assert.equal(fixture.decisionRegistry.inspect().phase, "admissions_closed"); return context(fixture); }, + (fixture: ReturnType) => { fixture.decisionRegistry.consumeForAct({ principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: fixture.red.token, atTick: 0 }); fixture.decisionRegistry.consumeForAct({ principal: "principal-blue", runId: "run-1", worldInstanceId: "instance-1", token: fixture.blue.token, atTick: 0 }); fixture.decisionRegistry.beginCutoff(0); fixture.decisionRegistry.closeAdmissions(0); fixture.decisionRegistry.finalize(0); assert.equal(fixture.decisionRegistry.inspect().phase, "finalized"); return context(fixture); }, + ]; + for (const prepare of cases) { + let calls = 0; const fixture = runtimeFixtureWithHooks({ observe: () => { calls += 1; return { channels: [] }; }, project: () => { calls += 1; return { channels: [] }; }, available: () => { calls += 1; return true; } }); + const request = prepare(fixture); const decision = fixture.decisionRegistry.snapshot(); const dynamics = fixture.dynamics.snapshot(); denied(() => fixture.runtime!.affordances(request)); + assert.equal(calls, 0); assert.deepEqual(fixture.decisionRegistry.snapshot(), decision); assert.deepEqual(fixture.dynamics.snapshot(), dynamics); + assert.deepEqual(fixture.readLedger.read(request.principal, {}).records.map((record) => [record.operation, record.result]), [["affordances", "denied"]]); + } +}); diff --git a/src/world/affordances.test.ts b/src/world/affordances.test.ts new file mode 100644 index 0000000..342bbed --- /dev/null +++ b/src/world/affordances.test.ts @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import { createHash } from "node:crypto"; +import test from "node:test"; + +import { canonicalDynamicsJson } from "../dynamics/canonicalJson.js"; +import { DYNAMICS_ACTION_RETENTION_LIMITS, DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import type { DynamicsSession } from "../dynamics/session.js"; +import { WorldRuntimeError } from "./ledger.js"; +import { runtimeFixture, runtimeFixtureWithHooks } from "./runtime.test-helper.js"; + +const denied = (call: () => unknown): void => assert.throws(call, (error: unknown) => + error instanceof WorldRuntimeError && error.code === "world_runtime_denied"); +const action = (id: string) => ({ act_id: id, action: "wait", actor: "object:red", at_tick: 0, + input: {}, origin: "test", principal_id: "principal-red", target: "object:red" }); +const snapshotBytes = (dynamics: DynamicsSession): Buffer => Buffer.from(JSON.stringify(dynamics.snapshot())); +const digest = (bytes: Uint8Array): string => createHash("sha256").update(bytes).digest("hex"); +const fillRetainedIngress = (dynamics: DynamicsSession): void => { + for (let index = 0; index < DYNAMICS_ACTION_RETENTION_LIMITS.records; index += 1) { + const suffix = index.toString().padStart(4, "0"); + const identifier = `${"\0".repeat(DYNAMICS_LIMITS.identifier_code_units - suffix.length)}${suffix}`; + const receipt = dynamics.queueAction({ + act_id: identifier, + action: "wait", + actor: "object:red", + at_tick: 1, + input: {}, + origin: "agentic", + principal_id: identifier, + target: "object:red", + }); + assert.equal(receipt.queued, false); + } +}; + +test("reports only each principal's granted available affordances in frozen canonical bytes", () => { + const fixture = runtimeFixture(); + const before = fixture.dynamics.snapshot(); + const red = fixture.runtime.affordances({ principal: "principal-red", decisionToken: fixture.red.token }); + const blue = fixture.runtime.affordances({ principal: "principal-blue", decisionToken: fixture.blue.token }); + assert.deepEqual(red.affordances, [ + { address: "world://pitch/affordance/kick", targets: ["world://pitch/entity/ball"] }, + { address: "world://pitch/affordance/wait", targets: ["world://pitch/entity/red"] }, + ]); + assert.deepEqual(blue.affordances, [{ address: "world://pitch/affordance/wait", targets: ["world://pitch/entity/blue"] }]); + assert.ok(Object.isFrozen(red)); + assert.ok(Object.isFrozen(red.affordances)); + assert.ok(Object.isFrozen(red.affordances[0]!.targets)); + assert.equal(JSON.stringify(red).includes("sense:state"), false); + assert.equal(JSON.stringify(red).includes(fixture.red.token), false); + assert.deepEqual(fixture.dynamics.snapshot(), before); + assert.deepEqual(fixture.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["affordances", "allowed"]]); +}); + +test("keeps repeated successful availability reads decision- and session-pure", () => { + let providers = 0; let projections = 0; let availability = 0; + const fixture = runtimeFixtureWithHooks({ + observe: (input) => { providers += 1; return { channels: [{ components: { x: 1 }, sense_address: (input as { sense_addresses: readonly string[] }).sense_addresses[0]!, subject_address: "object:red" }] }; }, + project: (input) => { projections += 1; return { channels: input.observation.channels.map((channel) => ({ ...channel, sense_address: "sense:vision", subject_address: "entity:red" })) }; }, + available: () => { availability += 1; return true; }, + }, true, { runId: "run-1", worldInstanceId: "instance-1", redSenses: ["world://pitch/sense/vision"], redAffordances: ["world://pitch/affordance/wait"] }); + const decisions = fixture.decisionRegistry.snapshot(); + const session = fixture.dynamics.snapshot(); + const first = fixture.runtime!.affordances({ principal: "principal-red", decisionToken: fixture.red.token }); + const second = fixture.runtime!.affordances({ principal: "principal-red", decisionToken: fixture.red.token }); + assert.deepEqual(first, second); + assert.notEqual(first, second); + assert.notEqual(first.affordances, second.affordances); + assert.deepEqual([providers, projections, availability], [2, 2, 2]); + assert.deepEqual(fixture.decisionRegistry.snapshot(), decisions); + assert.deepEqual(fixture.dynamics.snapshot(), session); + assert.deepEqual(fixture.readLedger.read("principal-red", {}).records.map((record) => record.result), ["allowed", "allowed"]); +}); + +test("collects every granted sense into a source-isolated frozen availability observation", () => { + const providers: unknown[] = []; + const availability: unknown[] = []; + const fixture = runtimeFixtureWithHooks({ + observe: (input) => { + providers.push(input); + const sense = (input as { sense_addresses: readonly string[] }).sense_addresses[0]!; + return { channels: [{ components: { x: sense === "sense:state" ? 7 : 9 }, sense_address: sense, subject_address: "object:red" }] }; + }, + project: (input) => ({ channels: input.observation.channels.map((channel) => ({ components: channel.components, sense_address: "sense:vision", subject_address: "entity:red" })) }), + available: (input) => { availability.push(input); return input.observation.channels.some((channel) => channel.components.x === 7); }, + }); + const result = fixture.runtime!.affordances({ principal: "principal-red", decisionToken: fixture.red.token }); + assert.deepEqual(providers.map((input) => (input as { sense_addresses: readonly string[] }).sense_addresses), [["sense:detail"], ["sense:state"]]); + assert.equal(availability.length, 2); + assert.deepEqual((availability[0] as { observation: { channels: unknown[] } }).observation.channels.map((channel) => (channel as { components: { x: number } }).components.x), [9, 7]); + assert.ok(Object.isFrozen(availability[0])); + assert.deepEqual(result.affordances.map((affordance) => affordance.address), ["world://pitch/affordance/kick", "world://pitch/affordance/wait"]); +}); + +test("retains mixed fixed targets, omits all-false affordances, and keeps selector order canonical", () => { + const targets: string[] = []; + const fixture = runtimeFixtureWithHooks({ + available: (input) => { targets.push(input.target); return input.target === "entity:blue" || input.target === "entity:red"; }, + }, true, { runId: "run-1", worldInstanceId: "instance-1", fixedTargets: ["entity:blue", "entity:ball"] }); + const result = fixture.runtime!.affordances({ principal: "principal-red", decisionToken: fixture.red.token }); + assert.deepEqual(targets, ["entity:ball", "entity:blue", "entity:red"]); + assert.deepEqual(result.affordances, [ + { address: "world://pitch/affordance/kick", targets: ["world://pitch/entity/blue"] }, + { address: "world://pitch/affordance/wait", targets: ["world://pitch/entity/red"] }, + ]); + const none = runtimeFixtureWithHooks({ available: () => false }); + assert.deepEqual(none.runtime!.affordances({ principal: "principal-red", decisionToken: none.red.token }).affordances, []); +}); + +test("uses the exact empty checked observation and no callbacks for an empty affordance grant", () => { + let providers = 0; + let callbacks = 0; + const empty = runtimeFixtureWithHooks({ observe: () => { providers += 1; return { channels: [] }; }, available: () => { callbacks += 1; return true; } }, true, + { runId: "run-1", worldInstanceId: "instance-1", redAffordances: [] }); + assert.deepEqual(empty.runtime!.affordances({ principal: "principal-red", decisionToken: empty.red.token }).affordances, []); + assert.equal(providers, 0); + assert.equal(callbacks, 0); + let seen: unknown; + const noSenses = runtimeFixtureWithHooks({ available: (input) => { seen = input.observation; return true; } }, true, + { runId: "run-1", worldInstanceId: "instance-1", redSenses: [], redAffordances: ["world://pitch/affordance/wait"] }); + noSenses.runtime!.affordances({ principal: "principal-red", decisionToken: noSenses.red.token }); + assert.deepEqual(JSON.parse(JSON.stringify(seen)), { channels: [] }); + assert.ok(Object.isFrozen(seen)); +}); + +test("denies aggregate public observations over the channel bound before availability", () => { + let available = 0; + const channels = (sense: string, count: number) => Array.from({ length: count }, (_, index) => ({ components: { x: index }, frame: `frame:f${index}`, sense_address: sense, subject_address: "entity:red" })); + const fixture = runtimeFixtureWithHooks({ + project: () => ({ channels: channels("sense:vision", 129) }), + projectDetail: () => ({ channels: channels("sense:red-detail", 128) }), + available: () => { available += 1; return true; }, + }); + const before = fixture.dynamics.snapshot(); + denied(() => fixture.runtime!.affordances({ principal: "principal-red", decisionToken: fixture.red.token })); + assert.equal(available, 0); + assert.deepEqual(fixture.dynamics.snapshot(), before); +}); + +test("denies duplicate aggregate channels before availability", () => { + let available = 0; + const fixture = runtimeFixtureWithHooks({ + project: () => ({ channels: [{ components: { x: 1 }, sense_address: "sense:shared", subject_address: "entity:red" }] }), + projectDetail: () => ({ channels: [{ components: { x: 1 }, sense_address: "sense:shared", subject_address: "entity:red" }] }), + available: () => { available += 1; return true; }, + }); + const before = fixture.dynamics.snapshot(); + denied(() => fixture.runtime!.affordances({ principal: "principal-red", decisionToken: fixture.red.token })); + assert.equal(available, 0); + assert.deepEqual(fixture.dynamics.snapshot(), before); +}); + +test("denies hostile availability callbacks and restores the complete outer session checkpoint", () => { + const hostile = [ + (_dynamics: DynamicsSession) => { throw new Error("available"); }, + (_dynamics: DynamicsSession) => Promise.resolve(true), + (_dynamics: DynamicsSession) => "true", + (dynamics: DynamicsSession) => { dynamics.queueAction(action("available-true")); return true; }, + (dynamics: DynamicsSession) => { dynamics.queueAction(action("available-false")); return false; }, + (dynamics: DynamicsSession) => { dynamics.queueAction(action("available-throw")); throw new Error("available"); }, + (dynamics: DynamicsSession) => { dynamics.queueAction(action("available-promise")); return Promise.resolve(false); }, + (dynamics: DynamicsSession) => { dynamics.queueAction(action("available-malformed")); return {}; }, + (dynamics: DynamicsSession) => { dynamics.step(); return true; }, + ]; + for (const available of hostile) { + const fixture = runtimeFixtureWithHooks({ available: (_input, dynamics) => available(dynamics) }); + const before = fixture.dynamics.snapshot(); + denied(() => fixture.runtime!.affordances({ principal: "principal-red", decisionToken: fixture.red.token })); + assert.deepEqual(fixture.dynamics.snapshot(), before); + assert.deepEqual(fixture.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["affordances", "denied"]]); + } +}); + +test("keeps overflow-sized affordance failures byte-pure without normalizing retained receipts", () => { + const subject = runtimeFixtureWithHooks({ available: () => { throw new Error("available"); } }); + const control = runtimeFixtureWithHooks({ available: () => { throw new Error("available"); } }); + fillRetainedIngress(subject.dynamics); + fillRetainedIngress(control.dynamics); + const before = snapshotBytes(subject.dynamics); + assert.throws(() => canonicalDynamicsJson(subject.dynamics.snapshot()), /json_code_units|json nodes|JSON/u); + assert.deepEqual( + Object.keys(subject.dynamics.snapshot().action_ingress[0]!.receipt), + ["act_id", "apply_tick", "code", "queued"], + ); + denied(() => subject.runtime!.affordances({ principal: "principal-red", decisionToken: subject.red.token })); + const after = snapshotBytes(subject.dynamics); + assert.deepEqual(after, before); + assert.equal(digest(after), digest(before)); + assert.equal(subject.restoreCalls(), 0); + assert.deepEqual( + Object.keys(subject.dynamics.snapshot().action_ingress[0]!.receipt), + ["act_id", "apply_tick", "code", "queued"], + ); + assert.deepEqual(subject.dynamics.step(), control.dynamics.step()); + const subjectAfterStep = snapshotBytes(subject.dynamics); + const controlAfterStep = snapshotBytes(control.dynamics); + assert.deepEqual(subjectAfterStep, controlAfterStep); + assert.equal(digest(subjectAfterStep), digest(controlAfterStep)); +}); + +test("denies terminal admissions before provider, projection, or availability callbacks", () => { + const cases = [ + { phase: undefined, prepare: (fixture: ReturnType) => ({ token: fixture.blue.token }) }, + { phase: undefined, prepare: (fixture: ReturnType) => { fixture.dynamics.step(); fixture.dynamics.step(); fixture.dynamics.step(); return { token: fixture.red.token }; } }, + { phase: undefined, prepare: (fixture: ReturnType) => { fixture.decisionRegistry.consumeForAct({ principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: fixture.red.token, atTick: 0 }); return { token: fixture.red.token }; } }, + { phase: "admissions_closed", prepare: (fixture: ReturnType) => { fixture.decisionRegistry.consumeForAct({ principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: fixture.red.token, atTick: 0 }); fixture.decisionRegistry.consumeForAct({ principal: "principal-blue", runId: "run-1", worldInstanceId: "instance-1", token: fixture.blue.token, atTick: 0 }); fixture.decisionRegistry.beginCutoff(0); fixture.decisionRegistry.closeAdmissions(0); assert.equal(fixture.decisionRegistry.inspect().phase, "admissions_closed"); return { token: fixture.red.token }; } }, + { phase: "finalized", prepare: (fixture: ReturnType) => { fixture.decisionRegistry.consumeForAct({ principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: fixture.red.token, atTick: 0 }); fixture.decisionRegistry.consumeForAct({ principal: "principal-blue", runId: "run-1", worldInstanceId: "instance-1", token: fixture.blue.token, atTick: 0 }); fixture.decisionRegistry.beginCutoff(0); fixture.decisionRegistry.closeAdmissions(0); fixture.decisionRegistry.finalize(0); assert.equal(fixture.decisionRegistry.inspect().phase, "finalized"); return { token: fixture.red.token }; } }, + ]; + for (const entry of cases) { + let calls = 0; + const fixture = runtimeFixtureWithHooks({ observe: () => { calls += 1; return { channels: [] }; }, project: () => { calls += 1; return { channels: [] }; }, available: () => { calls += 1; return true; } }); + const context = entry.prepare(fixture); + denied(() => fixture.runtime!.affordances({ principal: "principal-red", decisionToken: context.token })); + assert.equal(calls, 0); + } +}); + +test("keeps availability reentry to one nested denial and one outer audit, and filters the ledger", () => { + let caughtCounts = [0, 0, 0]; + let caught: ReturnType; + caught = runtimeFixtureWithHooks({ observe: (input) => { caughtCounts[0] += 1; return { channels: [{ components: { x: 1 }, sense_address: (input as { sense_addresses: readonly string[] }).sense_addresses[0]!, subject_address: "object:red" }] }; }, project: (input) => { caughtCounts[1] += 1; return { channels: input.observation.channels.map((channel) => ({ ...channel, sense_address: "sense:vision", subject_address: "entity:red" })) }; }, available: () => { caughtCounts[2] += 1; try { caught.runtime!.affordances({ principal: "principal-red", decisionToken: caught.red.token }); } catch { /* Nested call is denied. */ } return true; } }, true, { runId: "run-1", worldInstanceId: "instance-1", redSenses: ["world://pitch/sense/vision"], redAffordances: ["world://pitch/affordance/wait"] }); + const caughtState = caught.dynamics.snapshot(); + caught.runtime!.affordances({ principal: "principal-red", decisionToken: caught.red.token }); + assert.deepEqual(caughtCounts, [1, 1, 1]); assert.deepEqual(caught.dynamics.snapshot(), caughtState); + assert.deepEqual(caught.readLedger.read("principal-red", { operations: ["affordances"] }).records.map((record) => [record.operation, record.result]), [["affordances", "denied"], ["affordances", "allowed"]]); + const uncaughtCounts = [0, 0, 0]; let uncaught: ReturnType; + uncaught = runtimeFixtureWithHooks({ observe: (input) => { uncaughtCounts[0] += 1; return { channels: [{ components: { x: 1 }, sense_address: (input as { sense_addresses: readonly string[] }).sense_addresses[0]!, subject_address: "object:red" }] }; }, project: (input) => { uncaughtCounts[1] += 1; return { channels: input.observation.channels.map((channel) => ({ ...channel, sense_address: "sense:vision", subject_address: "entity:red" })) }; }, available: () => { uncaughtCounts[2] += 1; return uncaught.runtime!.affordances({ principal: "principal-red", decisionToken: uncaught.red.token }) as never; } }, true, { runId: "run-1", worldInstanceId: "instance-1", redSenses: ["world://pitch/sense/vision"], redAffordances: ["world://pitch/affordance/wait"] }); + const uncaughtState = uncaught.dynamics.snapshot(); + denied(() => uncaught.runtime!.affordances({ principal: "principal-red", decisionToken: uncaught.red.token })); + assert.deepEqual(uncaughtCounts, [1, 1, 1]); assert.deepEqual(uncaught.dynamics.snapshot(), uncaughtState); + assert.deepEqual(uncaught.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["affordances", "denied"], ["affordances", "denied"]]); +}); + +test("keeps provider and projection affordance reentry caught or uncaught before another callback", () => { + const options = { runId: "run-1", worldInstanceId: "instance-1", redAffordances: ["world://pitch/affordance/wait"] as const, redSenses: ["world://pitch/sense/vision"] as const }; + const providerCounts = [0, 0, 0]; let caughtProvider: ReturnType; + caughtProvider = runtimeFixtureWithHooks({ observe: (input) => { providerCounts[0] += 1; try { caughtProvider.runtime!.affordances({ principal: "principal-red", decisionToken: caughtProvider.red.token }); } catch { /* Nested call is denied. */ } return { channels: [{ components: { x: 1 }, sense_address: (input as { sense_addresses: readonly string[] }).sense_addresses[0]!, subject_address: "object:red" }] }; }, project: (input) => { providerCounts[1] += 1; return { channels: input.observation.channels.map((channel) => ({ ...channel, sense_address: "sense:vision", subject_address: "entity:red" })) }; }, available: () => { providerCounts[2] += 1; return true; } }, true, options); + const providerState = caughtProvider.dynamics.snapshot(); + caughtProvider.runtime!.affordances({ principal: "principal-red", decisionToken: caughtProvider.red.token }); + assert.deepEqual(providerCounts, [1, 1, 1]); assert.deepEqual(caughtProvider.dynamics.snapshot(), providerState); + assert.deepEqual(caughtProvider.readLedger.read("principal-red", {}).records.map((record) => record.result), ["denied", "allowed"]); + const uncaughtProviderCounts = [0, 0, 0]; let uncaughtProvider: ReturnType; + uncaughtProvider = runtimeFixtureWithHooks({ observe: () => { uncaughtProviderCounts[0] += 1; return uncaughtProvider.runtime!.affordances({ principal: "principal-red", decisionToken: uncaughtProvider.red.token }) as never; }, project: () => { uncaughtProviderCounts[1] += 1; return { channels: [] }; }, available: () => { uncaughtProviderCounts[2] += 1; return true; } }, true, options); + const uncaughtProviderState = uncaughtProvider.dynamics.snapshot(); + denied(() => uncaughtProvider.runtime!.affordances({ principal: "principal-red", decisionToken: uncaughtProvider.red.token })); + assert.deepEqual(uncaughtProviderCounts, [1, 0, 0]); assert.deepEqual(uncaughtProvider.dynamics.snapshot(), uncaughtProviderState); + assert.deepEqual(uncaughtProvider.readLedger.read("principal-red", {}).records.map((record) => record.result), ["denied", "denied"]); + const projectionCounts = [0, 0, 0]; let caughtProjection: ReturnType; + caughtProjection = runtimeFixtureWithHooks({ observe: (input) => { projectionCounts[0] += 1; return { channels: [{ components: { x: 1 }, sense_address: (input as { sense_addresses: readonly string[] }).sense_addresses[0]!, subject_address: "object:red" }] }; }, project: (input) => { projectionCounts[1] += 1; try { caughtProjection.runtime!.affordances({ principal: "principal-red", decisionToken: caughtProjection.red.token }); } catch { /* Nested call is denied. */ } return { channels: input.observation.channels.map((channel) => ({ components: channel.components, sense_address: "sense:vision", subject_address: "entity:red" })) }; }, available: () => { projectionCounts[2] += 1; return true; } }, true, options); + const projectionState = caughtProjection.dynamics.snapshot(); + caughtProjection.runtime!.affordances({ principal: "principal-red", decisionToken: caughtProjection.red.token }); + assert.deepEqual(projectionCounts, [1, 1, 1]); assert.deepEqual(caughtProjection.dynamics.snapshot(), projectionState); + assert.deepEqual(caughtProjection.readLedger.read("principal-red", {}).records.map((record) => record.result), ["denied", "allowed"]); + const uncaughtProjectionCounts = [0, 0, 0]; let uncaughtProjection: ReturnType; + uncaughtProjection = runtimeFixtureWithHooks({ observe: (input) => { uncaughtProjectionCounts[0] += 1; return { channels: [{ components: { x: 1 }, sense_address: (input as { sense_addresses: readonly string[] }).sense_addresses[0]!, subject_address: "object:red" }] }; }, project: () => { uncaughtProjectionCounts[1] += 1; return uncaughtProjection.runtime!.affordances({ principal: "principal-red", decisionToken: uncaughtProjection.red.token }) as never; }, available: () => { uncaughtProjectionCounts[2] += 1; return true; } }, true, options); + const uncaughtProjectionState = uncaughtProjection.dynamics.snapshot(); + denied(() => uncaughtProjection.runtime!.affordances({ principal: "principal-red", decisionToken: uncaughtProjection.red.token })); + assert.deepEqual(uncaughtProjectionCounts, [1, 1, 0]); assert.deepEqual(uncaughtProjection.dynamics.snapshot(), uncaughtProjectionState); + assert.deepEqual(uncaughtProjection.readLedger.read("principal-red", {}).records.map((record) => record.result), ["denied", "denied"]); +}); diff --git a/src/world/affordances.ts b/src/world/affordances.ts new file mode 100644 index 0000000..72963b9 --- /dev/null +++ b/src/world/affordances.ts @@ -0,0 +1,100 @@ +import { compareUtf16 } from "../dynamics/buildIdentity.js"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { sameDynamicsSessionSnapshot } from "../dynamics/sameDynamicsSessionSnapshot.js"; +import type { DynamicsSession } from "../dynamics/session.js"; +import type { WorldSurfaceRegistry } from "../world-surface/index.js"; +import { parseWorldSurfaceObservation } from "../world-surface/observation.js"; +import type { LocalResourceReference } from "./addresses.js"; +import { resolveWorldAddress, type CanonicalWorldAddress } from "./addresses.js"; +import type { CapabilityManifest } from "./capabilityManifest.js"; +import { observeScopedWorldRuntime } from "./observe.js"; +import type { WorldRuntimeIdentity } from "./runtime.js"; + +export interface WorldRuntimeAffordance { + readonly address: CanonicalWorldAddress; + readonly targets: readonly CanonicalWorldAddress[]; +} + +export interface WorldRuntimeAffordances { + readonly identity: WorldRuntimeIdentity; + readonly affordances: readonly WorldRuntimeAffordance[]; +} + +type Dependencies = Readonly<{ dynamics: DynamicsSession; surfaceRegistry: WorldSurfaceRegistry }>; +type LocalAffordance = { readonly address: LocalResourceReference; readonly targets: readonly LocalResourceReference[]; readonly canonicalTargets: readonly CanonicalWorldAddress[] }; + +const canonical = (worldId: string, local: LocalResourceReference): CanonicalWorldAddress => + resolveWorldAddress({ id: worldId as never }, local); +const checkpoint = (dynamics: DynamicsSession): ReturnType => dynamics.snapshot(); +const restore = (dynamics: DynamicsSession, value: ReturnType): void => { try { dynamics.restore(value); } catch { /* The caller denies. */ } }; +const unchanged = (dynamics: DynamicsSession, before: ReturnType): boolean => { + try { if (sameDynamicsSessionSnapshot(dynamics.snapshot(), before)) return true; } catch { /* Restore below. */ } + restore(dynamics, before); + return false; +}; +const fail = (): never => { throw new Error("denied"); }; + +const localAffordances = (manifest: CapabilityManifest, registry: WorldSurfaceRegistry): readonly LocalAffordance[] => { + if (manifest.affordances.length > DYNAMICS_LIMITS.sense_grants) return fail(); + const entities = new Map(registry.entities.map((entry) => [canonical(manifest.world.id, entry.address), entry.address])); + const output: LocalAffordance[] = []; + for (const granted of manifest.affordances) { + const entry = registry.affordances.find((candidate) => canonical(manifest.world.id, candidate.address) === granted.address); + if (entry === undefined) return fail(); + const declared = entry.target_selector; + if (declared.kind !== granted.target_selector.kind) return fail(); + const canonicalTargets = granted.target_selector.kind === "holder" + ? [manifest.holder.entity] + : [...granted.target_selector.targets]; + if (canonicalTargets.length > DYNAMICS_LIMITS.sense_grants + || canonicalTargets.some((target, index) => index > 0 && compareUtf16(canonicalTargets[index - 1]!, target) >= 0)) return fail(); + const targets = canonicalTargets.map((target) => { + const local = entities.get(target); if (local === undefined) return fail(); return local; + }); + const declaredTargets = declared.kind === "holder" ? [manifest.holder.entity] + : declared.targets.map((target) => canonical(manifest.world.id, target)).sort(compareUtf16); + if (declaredTargets.length !== canonicalTargets.length + || declaredTargets.some((target, index) => target !== canonicalTargets[index])) return fail(); + output.push({ address: entry.address, targets: Object.freeze(targets), canonicalTargets: Object.freeze(canonicalTargets) }); + } + return Object.freeze(output); +}; + +/** @internal B67 evaluator; public callers use WorldRuntime.affordances. */ +export const affordancesWorldRuntime = (dependencies: Dependencies, manifest: CapabilityManifest, identity: WorldRuntimeIdentity): WorldRuntimeAffordances => { + const afforded = localAffordances(manifest, dependencies.surfaceRegistry); + if (afforded.length === 0) return Object.freeze({ identity: Object.freeze({ ...identity }), affordances: Object.freeze([]) }); + let outer: ReturnType; + try { outer = checkpoint(dependencies.dynamics); } catch { return fail(); } + try { + if (dependencies.dynamics.nextTick !== identity.state_version) return fail(); + const projected = manifest.senses.map((sense) => observeScopedWorldRuntime(dependencies, manifest, identity, sense.address).observation.channels); + const observation = parseWorldSurfaceObservation({ channels: projected.flat() }, "aggregate affordance observation"); + if (dependencies.dynamics.nextTick !== identity.state_version) return fail(); + const holder = dependencies.surfaceRegistry.entities.find((entry) => canonical(manifest.world.id, entry.address) === manifest.holder.entity); + if (holder === undefined) return fail(); + const results: WorldRuntimeAffordance[] = []; + let totalTargets = 0; + for (const affordance of afforded) { + const targets: CanonicalWorldAddress[] = []; + for (const [index, target] of affordance.targets.entries()) { + const before = checkpoint(dependencies.dynamics); + let available: boolean; + try { available = dependencies.surfaceRegistry.isAffordanceAvailable(affordance.address, Object.freeze({ holder: holder.address, observation, target })); } + catch { unchanged(dependencies.dynamics, before); return fail(); } + if (!unchanged(dependencies.dynamics, before) || dependencies.dynamics.nextTick !== identity.state_version) return fail(); + if (available) targets.push(affordance.canonicalTargets[index]!); + } + if (targets.length > 0) { + totalTargets += targets.length; + if (totalTargets > DYNAMICS_LIMITS.sense_grants * DYNAMICS_LIMITS.sense_grants) return fail(); + results.push(Object.freeze({ address: canonical(manifest.world.id, affordance.address), targets: Object.freeze(targets.sort(compareUtf16)) })); + } + } + if (!unchanged(dependencies.dynamics, outer) || dependencies.dynamics.nextTick !== identity.state_version) return fail(); + return Object.freeze({ identity: Object.freeze({ ...identity }), affordances: Object.freeze(results.sort((left, right) => compareUtf16(left.address, right.address))) }); + } catch { + unchanged(dependencies.dynamics, outer); + return fail(); + } +}; diff --git a/src/world/boundaryObserver.ts b/src/world/boundaryObserver.ts new file mode 100644 index 0000000..0e4e4f1 --- /dev/null +++ b/src/world/boundaryObserver.ts @@ -0,0 +1,32 @@ +export interface WorldBoundaryObservation { + readonly operation: string; + readonly principal: string; + readonly request: Uint8Array; +} + +export interface WorldBoundaryReservation { + complete(input: Readonly<{ readonly status: number; readonly response: Uint8Array }>): void; +} + +export interface WorldBoundaryObserver { + begin(input: WorldBoundaryObservation): WorldBoundaryReservation; +} + +const observers = new WeakMap(); + +/** Host-only registration. The observer receives no bearer credential. */ +export const registerWorldBoundaryObserver = ( + runtime: object, + observer: WorldBoundaryObserver, +): void => { + if (runtime === null || typeof runtime !== "object" || observers.has(runtime) + || observer === null || typeof observer !== "object" || typeof observer.begin !== "function") { + throw new TypeError("invalid world boundary observer registration"); + } + observers.set(runtime, observer); +}; + +export const readWorldBoundaryObserver = ( + runtime: unknown, +): WorldBoundaryObserver | undefined => + runtime !== null && typeof runtime === "object" ? observers.get(runtime) : undefined; diff --git a/src/world/capabilityManifest.test.ts b/src/world/capabilityManifest.test.ts new file mode 100644 index 0000000..0354d35 --- /dev/null +++ b/src/world/capabilityManifest.test.ts @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import { Buffer } from "node:buffer"; +import test from "node:test"; + +import { parseWorldSurfaceDefinition } from "../world-surface/index.js"; +import { validWorldSurface } from "../world-surface/definition.test-helper.js"; +import { + compileCapabilityManifests, + parseCapabilityManifest, + serializeCapabilityManifest, + type CapabilityManifestCompilationInput +} from "./capabilityManifest.js"; +import type { WorldAddressScope } from "./addresses.js"; + +const text = (bytes: readonly number[]): string => new TextDecoder().decode(Uint8Array.from(bytes)); +const copy = (value: Value): Value => JSON.parse(JSON.stringify(value)) as Value; +type RootOnlyWorld = CapabilityManifestCompilationInput["world"]; +const rootScopeRejectsAncestors: WorldAddressScope extends RootOnlyWorld ? false : true = true; + +const rawSurface = () => { + const surface = validWorldSurface() as { + entities: Record; + senses: Record; + affordances: Record; + effects: Record; + }; + surface.entities.blue = { address: "entity:blue", dynamics_address: "object:player.blue" }; + surface.senses["sense:blue-view"] = { + dynamics_senses: ["sense:state"], output: "simfile.numeric-observation.v1", project: () => ({ channels: [] }) + }; + surface.affordances["affordance:wait"] = { + ...surface.affordances["affordance:kick"] as object, + dynamics_action: "wait", + target_selector: { kind: "holder" } + }; + return surface; +}; + +const input = (): CapabilityManifestCompilationInput => ({ + runId: "run-1", + worldInstanceId: "instance-1", + world: { id: "pitch" as never }, + surfaceRegistry: parseWorldSurfaceDefinition(rawSurface()), + grants: [{ + participant: "red", principal: "principal-red", entity: "world://pitch/entity/red" as never, + senses: ["world://pitch/sense/vision" as never], affordances: ["world://pitch/affordance/kick" as never] + }, { + participant: "blue", principal: "principal-blue", entity: "world://pitch/entity/blue" as never, + senses: ["world://pitch/sense/blue-view" as never], affordances: ["world://pitch/affordance/wait" as never] + }] +}); + +test("compiles one principal-sorted, default-deny manifest per bound grant", () => { + const artifacts = compileCapabilityManifests(input()); + assert.deepEqual(artifacts.map((item) => item.manifest.holder.principal), ["principal-blue", "principal-red"]); + const redArtifact = artifacts[1]!; + const blueArtifact = artifacts[0]!; + const red = redArtifact.manifest; + const blue = blueArtifact.manifest; + assert.equal(red.holder.entity, "world://pitch/entity/red"); + assert.equal(blue.holder.entity, "world://pitch/entity/blue"); + assert.deepEqual(red.senses.map(({ address }) => address), ["world://pitch/sense/vision"]); + assert.deepEqual(blue.affordances.map(({ address }) => address), ["world://pitch/affordance/wait"]); + assert.notEqual(redArtifact.digest, blueArtifact.digest); + assert.equal(text(redArtifact.bytes).includes("effect"), false); + assert.equal(text(redArtifact.bytes).includes("credential"), false); + assert.equal(compileCapabilityManifests({ ...input(), grants: [input().grants[0]!] }).length, 1); +}); + +test("rejects non-B18 composition data, address casts, duplicates, and effects", () => { + const baseline = input(); + for (const bad of [ + { ...baseline, bearer: "secret" }, + { ...baseline, grants: [{ ...baseline.grants[0]!, holder: "other" }] }, + { ...baseline, grants: [{ ...baseline.grants[0]!, entity: "world://other/entity/red" }] }, + { ...baseline, grants: [{ ...baseline.grants[0]!, entity: "world://pitch/sense/vision" }] }, + { ...baseline, grants: [{ ...baseline.grants[0]!, senses: ["world://pitch/effect/impact"] }] }, + { ...baseline, grants: [{ ...baseline.grants[0]!, senses: ["world://pitch/sense/vision", "world://pitch/sense/vision"] }] }, + { ...baseline, grants: [baseline.grants[0]!, { ...baseline.grants[1]!, principal: "principal-red" }] } + ]) assert.throws(() => compileCapabilityManifests(bad as never), /Capability manifest rejected/u); + + const forged = baseline.surfaceRegistry as unknown as Record; + assert.throws(() => compileCapabilityManifests({ ...baseline, surfaceRegistry: { ...forged } as never }), /Capability manifest rejected/u); +}); + +test("binds schemas, selectors, rejection codes, registry metadata, and identity into digests", () => { + const first = compileCapabilityManifests(input())[1]!.manifest; + const changedSchema = rawSurface() as any; + changedSchema.affordances["affordance:kick"].input_schema.properties.force.maximum = 2; + const schema = compileCapabilityManifests({ ...input(), surfaceRegistry: parseWorldSurfaceDefinition(changedSchema) })[1]!.manifest; + assert.notEqual(first.affordances[0]!.input_schema_digest, schema.affordances[0]!.input_schema_digest); + + const changedSelector = rawSurface() as any; + changedSelector.affordances["affordance:wait"].target_selector = { kind: "fixed", targets: ["entity:red"] }; + const selector = compileCapabilityManifests({ ...input(), surfaceRegistry: parseWorldSurfaceDefinition(changedSelector) })[1]!.manifest; + assert.notEqual(first.surface.registry_digest, selector.surface.registry_digest); + + const changedCodes = rawSurface() as any; + changedCodes.affordances["affordance:kick"].rejection_codes = ["blocked-next"]; + const codes = compileCapabilityManifests({ ...input(), surfaceRegistry: parseWorldSurfaceDefinition(changedCodes) })[1]!.manifest; + assert.notEqual(first.affordances[0]!.rejection_codes[0], codes.affordances[0]!.rejection_codes[0]); + + const changedEffect = rawSurface() as any; + changedEffect.effects["effect:impact"].dynamics_event = "impact-next"; + const registry = compileCapabilityManifests({ ...input(), surfaceRegistry: parseWorldSurfaceDefinition(changedEffect) })[1]!.manifest; + assert.notEqual(first.surface.registry_digest, registry.surface.registry_digest); + assert.notEqual(first.manifest_digest, compileCapabilityManifests({ ...input(), runId: "run-2" })[1]!.manifest.manifest_digest); +}); + +test("canonical bytes parse strictly and bind all self-contained digests", () => { + const artifact = compileCapabilityManifests(input())[0]!; + assert.deepEqual(parseCapabilityManifest(artifact.bytes), artifact.manifest); + assert.deepEqual(serializeCapabilityManifest(artifact.manifest), artifact.bytes); + const canonical = text(artifact.bytes); + for (const hostile of [ + Uint8Array.from(artifact.bytes.slice(0, -1)), + new TextEncoder().encode(`${JSON.stringify(artifact.manifest)}\n`), + new TextEncoder().encode(canonical.replace('"version":"simfile.capability-manifest.v1"', '"version":"simfile.capability-manifest.v1","version":"simfile.capability-manifest.v1"')), + new TextEncoder().encode(canonical.replace("run-1", "run-2")), + new TextEncoder().encode(canonical.replace(/sha256:[a-f0-9]{64}/u, `sha256:${"0".repeat(64)}`)), + Uint8Array.of(0xff) + ]) assert.throws(() => parseCapabilityManifest(hostile), /./u); +}); + +test("is deterministic, deeply immutable, source-isolated, and callback-free", () => { + let calls = 0; + const surface = rawSurface(); + for (const sense of Object.values(surface.senses) as Array<{ project: () => unknown }>) sense.project = () => { calls += 1; return { channels: [] }; }; + for (const affordance of Object.values(surface.affordances) as Array<{ available: () => boolean; lower: () => object }>) { + affordance.available = () => { calls += 1; return true; }; + affordance.lower = () => { calls += 1; return {}; }; + } + const checked = parseWorldSurfaceDefinition(surface); + const grants = input().grants.map((grant) => ({ ...grant, senses: [...grant.senses], affordances: [...grant.affordances] })); + const prepared = { ...input(), surfaceRegistry: checked, grants }; + const reversed = compileCapabilityManifests({ ...prepared, grants: [...prepared.grants].reverse() }); + const normal = compileCapabilityManifests(prepared); + assert.deepEqual(reversed.map(({ bytes }) => bytes), normal.map(({ bytes }) => bytes)); + assert.equal(calls, 0); + const beforeManifests = normal.map(({ manifest }) => text(serializeCapabilityManifest(manifest))); + const beforeBytes = normal.map(({ bytes }) => [...bytes]); + (surface.senses["sense:vision"] as { dynamics_senses: string[] }).dynamics_senses[0] = "sense:changed"; + ((surface.affordances["affordance:kick"] as { input_schema: { properties: { force: { maximum: number } } } }).input_schema.properties.force).maximum = 2; + grants[0]!.senses[0] = "world://pitch/sense/blue-view" as never; + grants[0]!.affordances[0] = "world://pitch/affordance/wait" as never; + assert.deepEqual(normal.map(({ manifest }) => text(serializeCapabilityManifest(manifest))), beforeManifests); + assert.deepEqual(normal.map(({ bytes }) => [...bytes]), beforeBytes); + assert.ok(Object.isFrozen(normal)); + assert.ok(Object.isFrozen(normal[0]!.bytes)); + assert.ok(Object.isFrozen(normal[0]!.manifest.affordances[0]!.input_schema)); + assert.throws(() => (normal[0]!.bytes as number[]).push(0), TypeError); + assert.throws(() => (normal[0]!.manifest.senses as unknown as unknown[]).push({}), TypeError); +}); + +test("accepts only parsed registries and canonicalizes separately parsed declarations", () => { + const first = rawSurface(); + const second = rawSurface(); + second.entities = Object.fromEntries(Object.entries(second.entities).reverse()); + second.senses = Object.fromEntries(Object.entries(second.senses).reverse()); + second.affordances = Object.fromEntries(Object.entries(second.affordances).reverse()); + second.effects = Object.fromEntries(Object.entries(second.effects).reverse()); + const one = compileCapabilityManifests({ ...input(), surfaceRegistry: parseWorldSurfaceDefinition(first) }); + const two = compileCapabilityManifests({ ...input(), surfaceRegistry: parseWorldSurfaceDefinition(second) }); + assert.deepEqual(one.map(({ bytes }) => bytes), two.map(({ bytes }) => bytes)); + assert.equal(one[0]!.manifest.surface.registry_digest, two[0]!.manifest.surface.registry_digest); + assert.equal(one[0]!.manifest.manifest_digest, two[0]!.manifest.manifest_digest); + const reorderedSenses = rawSurface(); + (reorderedSenses.senses["sense:vision"] as { dynamics_senses: string[] }).dynamics_senses = ["sense:state", "sense:aux"]; + const reversedSenses = rawSurface(); + (reversedSenses.senses["sense:vision"] as { dynamics_senses: string[] }).dynamics_senses = ["sense:aux", "sense:state"]; + const ordered = compileCapabilityManifests({ ...input(), surfaceRegistry: parseWorldSurfaceDefinition(reorderedSenses) }); + const reversedOrder = compileCapabilityManifests({ ...input(), surfaceRegistry: parseWorldSurfaceDefinition(reversedSenses) }); + assert.notEqual(ordered[0]!.manifest.surface.registry_digest, reversedOrder[0]!.manifest.surface.registry_digest); + assert.notEqual(ordered[0]!.manifest.manifest_digest, reversedOrder[0]!.manifest.manifest_digest); + assert.throws(() => compileCapabilityManifests({ ...input(), surfaceRegistry: { ...parseWorldSurfaceDefinition(rawSurface()) } as never }), /Capability manifest rejected/u); +}); + +test("redacts forged schemas and rejects hostile own data without execution", () => { + const artifact = compileCapabilityManifests(input())[0]!; + const secret = "SOL-SECRET-SENTINEL"; + const forged = copy(artifact.manifest) as any; + forged.affordances[0].input_schema.properties.holder = { type: "string", maxLength: 8, const: secret }; + const bearer = copy(artifact.manifest) as any; + bearer.affordances[0].input_schema.properties.bearer = { type: "string", maxLength: 8, const: secret }; + const actionEntity = copy(artifact.manifest) as any; + actionEntity.affordances[0].input_schema.properties.entity = { type: "string", maxLength: 8, const: secret }; + const nestedActionEntity = copy(artifact.manifest) as any; + nestedActionEntity.affordances[0].input_schema.properties.nested = { + additionalProperties: false, + properties: { entity: { type: "string", maxLength: 8, const: secret } }, + type: "object" + }; + for (const attempt of [ + () => serializeCapabilityManifest(forged), + () => parseCapabilityManifest(new TextEncoder().encode(`${JSON.stringify(forged)}\n`)), + () => serializeCapabilityManifest(bearer), + () => parseCapabilityManifest(new TextEncoder().encode(`${JSON.stringify(bearer)}\n`)), + () => serializeCapabilityManifest(actionEntity), + () => parseCapabilityManifest(new TextEncoder().encode(`${JSON.stringify(actionEntity)}\n`)), + () => serializeCapabilityManifest(nestedActionEntity), + () => parseCapabilityManifest(new TextEncoder().encode(`${JSON.stringify(nestedActionEntity)}\n`)) + ]) { + const error = assert.throws(attempt, /Capability manifest rejected/u); + assert.equal(String(error).includes(secret), false); + } + + let calls = 0; + const getter = copy(artifact.manifest) as any; + Object.defineProperty(getter.affordances[0].input_schema.properties.force, "maximum", { enumerable: true, get: () => { calls += 1; return 1; } }); + const indexAccessor = copy(artifact.manifest) as any; + Object.defineProperty(indexAccessor.affordances[0].rejection_codes, "0", { enumerable: true, get: () => { calls += 1; return "blocked"; } }); + const throwing = new Proxy(input(), { ownKeys: () => { calls += 1; throw new Error(secret); } }); + for (const attempt of [ + () => serializeCapabilityManifest(getter), + () => serializeCapabilityManifest(indexAccessor), + () => compileCapabilityManifests(throwing as never), + () => compileCapabilityManifests(new Proxy(input(), {}) as never) + ]) { + const error = assert.throws(attempt, /Capability manifest rejected/u); + assert.equal(String(error).includes(secret), false); + } + assert.equal(calls, 0); +}); + +test("revalidates participant records and hostile wire containers", () => { + const baseline = input(); + assert.throws(() => compileCapabilityManifests({ ...baseline, grants: [baseline.grants[0]!, { ...baseline.grants[1]!, participant: "red" }] }), /Capability manifest rejected/u); + assert.throws(() => compileCapabilityManifests({ ...baseline, grants: [{ ...baseline.grants[0]!, participant: "NOT VALID" }] }), /Capability manifest rejected/u); + const bytes = compileCapabilityManifests(baseline)[0]!.bytes; + assert.deepEqual(parseCapabilityManifest(Buffer.from(bytes)), parseCapabilityManifest(bytes)); + let iteratorCalls = 0; + const nativeBytes = Uint8Array.from(bytes); + Object.defineProperty(nativeBytes, Symbol.iterator, { value: () => { iteratorCalls += 1; return [0][Symbol.iterator](); } }); + assert.deepEqual(parseCapabilityManifest(nativeBytes), parseCapabilityManifest(bytes)); + assert.equal(iteratorCalls, 0); + class ByteSubclass extends Uint8Array {} + class ArraySubclass extends Array {} + const accessor = [...bytes]; + let traps = 0; + Object.defineProperty(accessor, "0", { enumerable: true, get: () => { throw new Error("SOL-SECRET-SENTINEL"); } }); + for (const hostile of [ + new ByteSubclass(bytes), new Proxy(Uint8Array.from(bytes), {}), + new Proxy(Uint8Array.from(bytes), { getPrototypeOf: () => { traps += 1; throw new Error("SOL-SECRET-SENTINEL"); } }), + new ArraySubclass(...bytes), new Proxy([...bytes], {}), accessor + ]) assert.throws(() => parseCapabilityManifest(hostile as never), /Capability manifest rejected/u); + const secret = "SOL-SECRET-SENTINEL"; + const subclassWithIterator = new ByteSubclass(bytes); + Object.defineProperty(subclassWithIterator, Symbol.iterator, { + value: () => { throw new Error(secret); } + }); + const error = assert.throws( + () => parseCapabilityManifest(subclassWithIterator), + /Capability manifest rejected/u + ); + assert.equal(String(error).includes(secret), false); + assert.throws(() => compileCapabilityManifests({ ...baseline, world: { id: "pitch" as never, ancestors: [] } as never }), /Capability manifest rejected/u); + assert.equal(rootScopeRejectsAncestors, true); + assert.equal(traps, 0); +}); diff --git a/src/world/capabilityManifest.ts b/src/world/capabilityManifest.ts new file mode 100644 index 0000000..174ef83 --- /dev/null +++ b/src/world/capabilityManifest.ts @@ -0,0 +1,237 @@ +import { types } from "node:util"; +import { Buffer } from "node:buffer"; + +import { canonicalJson, compareUtf16, deepFreeze, sha256 } from "../dynamics/buildIdentity.js"; +import { + assertNoWorldActionSchemaAuthorityFields, + assertNoWorldAuthoritySchemaFields, + parseBoundedJsonSchema, + WORLD_SURFACE_API_VERSION, + type WorldSurfaceRegistry +} from "../world-surface/index.js"; +import { readParsedWorldSurfaceRegistry } from "../world-surface/definition.js"; +import { parseSimfileIdentifier } from "../schema/identifier.js"; +import { + parseLocalResourceReference, + parseWorldId, + resolveWorldAddress, + type CanonicalWorldAddress, + type WorldId, + type WorldResourceKind +} from "./addresses.js"; +import type { BoundWorldGrant } from "./grants.js"; + +export const CAPABILITY_MANIFEST_VERSION = "simfile.capability-manifest.v1" as const; + +export interface CapabilityManifestCompilationInput { + readonly runId: string; + readonly worldInstanceId: string; + readonly world: { readonly id: WorldId; readonly ancestors?: never }; + readonly surfaceRegistry: WorldSurfaceRegistry; + readonly grants: readonly BoundWorldGrant[]; +} + +export interface CapabilityManifest { + readonly version: typeof CAPABILITY_MANIFEST_VERSION; + readonly run_id: string; + readonly world: { readonly id: string; readonly instance_id: string }; + readonly holder: { readonly principal: string; readonly entity: CanonicalWorldAddress }; + readonly surface: { readonly api_version: typeof WORLD_SURFACE_API_VERSION; readonly registry_digest: string }; + readonly senses: readonly { readonly address: CanonicalWorldAddress; readonly output: "simfile.numeric-observation.v1"; readonly output_schema_digest: string }[]; + readonly affordances: readonly { + readonly address: CanonicalWorldAddress; + readonly input_schema: Readonly>; + readonly input_schema_digest: string; + readonly rejection_codes: readonly string[]; + readonly target_selector: { readonly kind: "holder" } | { readonly kind: "fixed"; readonly targets: readonly CanonicalWorldAddress[] }; + }[]; + readonly manifest_digest: string; +} + +export interface CapabilityManifestArtifact { readonly manifest: CapabilityManifest; readonly bytes: readonly number[]; readonly digest: string; } + +type JsonRecord = Record; +type Selector = CapabilityManifest["affordances"][number]["target_selector"]; +type ResourceMaps = { + readonly entities: ReadonlySet; + readonly senses: ReadonlyMap; + readonly affordances: ReadonlyMap>; readonly rejection_codes: readonly string[]; readonly target_selector: Selector }>; + readonly descriptor: unknown; +}; + +const UTF8 = new TextEncoder(); +const TYPED_ARRAY_LENGTH = Object.getOwnPropertyDescriptor( + Object.getPrototypeOf(Uint8Array.prototype), + "length" +)!.get as (this: Uint8Array) => number; +const TYPED_ARRAY_SET = Uint8Array.prototype.set; +const SHA256 = /^sha256:[a-f0-9]{64}$/u; +const DOMAINS = { input: "simfile.capability-input-schema.v1\0", manifest: "simfile.capability-manifest.v1\0", output: "simfile.capability-output-schema.v1\0", registry: "simfile.world-surface-registry.v1\0" } as const; +const fail = (message: string): never => { throw new TypeError(`Capability manifest rejected: ${message}`); }; +const reject = (message: string): never => fail(message); +const bytes = (value: unknown): Uint8Array => UTF8.encode(`${canonicalJson(value)}\n`); +const digest = (domain: string, value: unknown): string => `sha256:${sha256(UTF8.encode(domain + new TextDecoder().decode(bytes(value))))}`; + +const isProxy = (value: unknown): boolean => value !== null && typeof value === "object" && types.isProxy(value as object); +const objectValues = (value: unknown, path: string): JsonRecord => { + if (!value || typeof value !== "object" || Array.isArray(value) || isProxy(value)) fail(`${path} has unsafe data`); + const source = value as object; const prototype = Object.getPrototypeOf(source); + if (prototype !== Object.prototype && prototype !== null) fail(`${path} must be a plain object`); + const output = Object.create(null) as JsonRecord; + for (const key of Reflect.ownKeys(source)) { + const descriptor = Object.getOwnPropertyDescriptor(source, key); + if (typeof key !== "string" || !descriptor?.enumerable || !("value" in descriptor)) fail(`${path} has unsafe data`); + Object.defineProperty(output, key, { configurable: true, enumerable: true, value: (descriptor as PropertyDescriptor & { value: unknown }).value, writable: true }); + } + return output; +}; +const record = (value: unknown, fields: readonly string[], path: string): JsonRecord => { + const output = objectValues(value, path); const keys = Object.keys(output); + if (keys.length !== fields.length || keys.some((key) => !fields.includes(key)) || fields.some((field) => !Object.hasOwn(output, field))) fail(`${path} has an invalid shape`); + return output; +}; +const array = (value: unknown, path: string): readonly unknown[] => { + if (!Array.isArray(value) || isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype) fail(`${path} must be a plain array`); + const source = value as unknown[]; const length = Object.getOwnPropertyDescriptor(source, "length"); + if (!length || !("value" in length) || !Number.isSafeInteger(length.value)) fail(`${path} has unsafe data`); + const count = (length as PropertyDescriptor & { value: number }).value; + const output: unknown[] = []; + for (let index = 0; index < count; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(source, String(index)); + if (!descriptor?.enumerable || !("value" in descriptor)) fail(`${path} has unsafe data`); + output.push((descriptor as PropertyDescriptor & { value: unknown }).value); + } + if (Reflect.ownKeys(source).length !== count + 1) fail(`${path} has unsafe data`); + return output; +}; +const json = (value: unknown, path: string): unknown => { + if (value === null || typeof value === "boolean" || typeof value === "string") return value; + if (typeof value === "number") { if (!Number.isFinite(value)) fail(`${path} must be JSON`); return Object.is(value, -0) ? 0 : value; } + if (Array.isArray(value)) return array(value, path).map((entry, index) => json(entry, `${path}[${index}]`)); + const source = objectValues(value, path); const output = Object.create(null) as JsonRecord; + for (const [key, child] of Object.entries(source)) Object.defineProperty(output, key, { configurable: true, enumerable: true, value: json(child, `${path}.${key}`), writable: true }); + return output; +}; +const schema = ( + value: unknown, + path: string, + assertAuthority: (schema: ReturnType, path: string) => void = + assertNoWorldAuthoritySchemaFields +): Readonly> => { + try { + const parsed = parseBoundedJsonSchema(json(value, path)); + assertAuthority(parsed, path); + return parsed as unknown as Readonly>; + } catch { return fail(`${path} is invalid`); } +}; +const binding = (value: unknown, path: string): string => { + if (typeof value !== "string" || value.length === 0 || value !== value.trim()) fail(`${path} must be a non-empty binding`); + return value as string; +}; +const sorted = (value: readonly Value[], path: string): readonly Value[] => { + for (let index = 1; index < value.length; index += 1) if (compareUtf16(value[index - 1]!.address, value[index]!.address) >= 0) fail(`${path} is not strictly address-ordered`); + return value; +}; +const absolute = (worldId: string, value: unknown, kinds: readonly WorldResourceKind[], path: string): CanonicalWorldAddress => { + if (typeof value !== "string") fail(`${path} must be an absolute world address`); + const address = value as string; + for (const kind of kinds) { + const prefix = `world://${worldId}/${kind}/`; + if (!address.startsWith(prefix)) continue; + try { + const resolved = resolveWorldAddress({ id: parseWorldId(worldId) }, parseLocalResourceReference(`${kind}:${address.slice(prefix.length)}`)); + if (resolved === address) return resolved; + } catch { /* Address grammar owns its diagnostics. */ } + } + return fail(`${path} is not a canonical address of the required kind`); +}; +const selector = (worldId: string, value: unknown, path: string): Selector => { + const shape = objectValues(value, path); const input = record(value, shape.kind === "fixed" ? ["kind", "targets"] : ["kind"], path); + if (input.kind === "holder") return deepFreeze({ kind: "holder" }); + if (input.kind !== "fixed") fail(`${path} is invalid`); + const targets = array(input.targets, `${path}.targets`).map((target, index) => absolute(worldId, target, ["entity"], `${path}.targets[${index}]`)); + if (targets.length === 0 || new Set(targets).size !== targets.length) fail(`${path} is invalid`); + return deepFreeze({ kind: "fixed", targets: [...targets].sort(compareUtf16) }); +}; + +const resourceMaps = (worldId: string, source: WorldSurfaceRegistry): ResourceMaps => { + const entities = new Set(); + const senses = new Map(); + const affordances = new Map>; rejection_codes: readonly string[]; target_selector: Selector }>(); + const entityDescriptor = source.entities.map((entry) => { const address = resolveWorldAddress({ id: parseWorldId(worldId) }, entry.address); entities.add(address); return { address, alias: entry.alias, dynamics_address: entry.dynamics_address }; }).sort((a, b) => compareUtf16(a.address, b.address)); + const senseDescriptor = source.senses.map((entry) => { const address = resolveWorldAddress({ id: parseWorldId(worldId) }, entry.address); const item = { address, output: entry.output }; senses.set(address, item); return { ...item, dynamics_senses: [...entry.dynamics_senses] }; }).sort((a, b) => compareUtf16(a.address, b.address)); + const affordanceDescriptor = source.affordances.map((entry) => { + const address = resolveWorldAddress({ id: parseWorldId(worldId) }, entry.address); const inputSchema = schema(entry.input_schema, "checked affordance schema", assertNoWorldActionSchemaAuthorityFields); + const target = entry.target_selector.kind === "holder" ? deepFreeze({ kind: "holder" } as const) : deepFreeze({ kind: "fixed" as const, targets: entry.target_selector.targets.map((item) => resolveWorldAddress({ id: parseWorldId(worldId) }, item)).sort(compareUtf16) }); + const item = { address, input_schema: inputSchema, rejection_codes: [...entry.rejection_codes].sort(compareUtf16), target_selector: target as Selector }; + affordances.set(address, item); return { ...item, dynamics_action: entry.dynamics_action }; + }).sort((a, b) => compareUtf16(a.address, b.address)); + const effects = source.effects.map((entry) => ({ address: resolveWorldAddress({ id: parseWorldId(worldId) }, entry.address), dynamics_event: entry.dynamics_event, payload_schema: schema(entry.payload_schema, "checked effect schema") })).sort((a, b) => compareUtf16(a.address, b.address)); + return { entities, senses, affordances, descriptor: { api_version: WORLD_SURFACE_API_VERSION, entities: entityDescriptor, senses: senseDescriptor, affordances: affordanceDescriptor, effects } }; +}; + +const validate = (value: unknown): CapabilityManifest => { + const source = record(value, ["version", "run_id", "world", "holder", "surface", "senses", "affordances", "manifest_digest"], "manifest"); + if (source.version !== CAPABILITY_MANIFEST_VERSION) fail("version is invalid"); + const runId = binding(source.run_id, "run_id"); const world = record(source.world, ["id", "instance_id"], "world"); const worldId = parseWorldId(world.id); const instanceId = binding(world.instance_id, "world.instance_id"); + const holder = record(source.holder, ["principal", "entity"], "holder"); const principal = binding(holder.principal, "holder.principal"); const entity = absolute(worldId, holder.entity, ["entity"], "holder.entity"); + const surface = record(source.surface, ["api_version", "registry_digest"], "surface"); if (surface.api_version !== WORLD_SURFACE_API_VERSION || typeof surface.registry_digest !== "string" || !SHA256.test(surface.registry_digest)) fail("surface is invalid"); + const senses = array(source.senses, "senses").map((entry, index) => { const item = record(entry, ["address", "output", "output_schema_digest"], `senses[${index}]`); const address = absolute(worldId, item.address, ["sense"], `senses[${index}].address`); if (item.output !== "simfile.numeric-observation.v1" || item.output_schema_digest !== digest(DOMAINS.output, { output: item.output })) fail(`senses[${index}] digest is invalid`); return { address, output: item.output as "simfile.numeric-observation.v1", output_schema_digest: item.output_schema_digest as string }; }); + sorted(senses, "senses"); + const affordances = array(source.affordances, "affordances").map((entry, index) => { const item = record(entry, ["address", "input_schema", "input_schema_digest", "rejection_codes", "target_selector"], `affordances[${index}]`); const address = absolute(worldId, item.address, ["affordance"], `affordances[${index}].address`); const inputSchema = schema(item.input_schema, `affordances[${index}].input_schema`, assertNoWorldActionSchemaAuthorityFields); if (item.input_schema_digest !== digest(DOMAINS.input, inputSchema)) fail(`affordances[${index}] digest is invalid`); const codes = array(item.rejection_codes, `affordances[${index}].rejection_codes`).map((code) => binding(code, `affordances[${index}].rejection_codes`)); if (new Set(codes).size !== codes.length || [...codes].sort(compareUtf16).some((code, codeIndex) => code !== codes[codeIndex])) fail(`affordances[${index}] rejection codes are not canonical`); return { address, input_schema: inputSchema, input_schema_digest: item.input_schema_digest as string, rejection_codes: codes, target_selector: selector(worldId, item.target_selector, `affordances[${index}].target_selector`) }; }); + sorted(affordances, "affordances"); + const core = { version: CAPABILITY_MANIFEST_VERSION, run_id: runId, world: { id: worldId, instance_id: instanceId }, holder: { principal, entity }, surface: { api_version: WORLD_SURFACE_API_VERSION, registry_digest: surface.registry_digest }, senses, affordances }; + if (typeof source.manifest_digest !== "string" || source.manifest_digest !== digest(DOMAINS.manifest, core)) fail("manifest digest is invalid"); + return deepFreeze({ ...core, manifest_digest: source.manifest_digest }) as CapabilityManifest; +}; + +const serialize = (manifest: CapabilityManifest): readonly number[] => deepFreeze(Array.from(bytes(validate(manifest)))); +export const serializeCapabilityManifest = (manifest: CapabilityManifest): readonly number[] => { try { return serialize(manifest); } catch { return reject("invalid manifest"); } }; + +const compile = (input: CapabilityManifestCompilationInput): readonly CapabilityManifestArtifact[] => { + const source = record(input, ["runId", "worldInstanceId", "world", "surfaceRegistry", "grants"], "compilation input"); const runId = binding(source.runId, "runId"); const instanceId = binding(source.worldInstanceId, "worldInstanceId"); + const scope = record(source.world, ["id"], "world"); const worldId = parseWorldId(scope.id); const checked = readParsedWorldSurfaceRegistry(source.surfaceRegistry); if (checked === undefined) fail("surface registry is not a parsed checked registry"); const registry = checked; + const maps = resourceMaps(worldId, registry as WorldSurfaceRegistry); const registryDigest = digest(DOMAINS.registry, maps.descriptor); const principals = new Set(); const participants = new Set(); const artifacts: CapabilityManifestArtifact[] = []; + for (const [index, rawGrant] of array(source.grants, "grants").entries()) { + const grant = record(rawGrant, ["participant", "principal", "entity", "senses", "affordances"], `grants[${index}]`); const participant = parseSimfileIdentifier(grant.participant); const principal = binding(grant.principal, `grants[${index}].principal`); + if (participants.has(participant) || principals.has(principal)) fail(`grants[${index}] duplicates participant or principal`); participants.add(participant); principals.add(principal); + const entity = absolute(worldId, grant.entity, ["entity"], `grants[${index}].entity`); if (!maps.entities.has(entity)) fail(`grants[${index}] entity is undeclared`); + const granted = (value: unknown, kind: "sense" | "affordance", path: string): readonly CanonicalWorldAddress[] => { const output = array(value, path).map((address, entryIndex) => absolute(worldId, address, [kind], `${path}[${entryIndex}]`)); if (new Set(output).size !== output.length) fail(`${path} contains duplicates`); return output.sort(compareUtf16); }; + const senses = granted(grant.senses, "sense", `grants[${index}].senses`).map((address) => { const definition = maps.senses.get(address); if (definition === undefined) return fail(`grants[${index}] sense is undeclared`); return { ...definition, output_schema_digest: digest(DOMAINS.output, { output: definition.output }) }; }); + const affordances = granted(grant.affordances, "affordance", `grants[${index}].affordances`).map((address) => { const definition = maps.affordances.get(address); if (definition === undefined) return fail(`grants[${index}] affordance is undeclared`); return { ...definition, input_schema_digest: digest(DOMAINS.input, definition.input_schema) }; }); + const core = { version: CAPABILITY_MANIFEST_VERSION, run_id: runId, world: { id: worldId, instance_id: instanceId }, holder: { principal, entity }, surface: { api_version: WORLD_SURFACE_API_VERSION, registry_digest: registryDigest }, senses, affordances }; + const manifest = validate({ ...core, manifest_digest: digest(DOMAINS.manifest, core) }); artifacts.push(deepFreeze({ manifest, bytes: serialize(manifest), digest: manifest.manifest_digest })); + } + return deepFreeze(artifacts.sort((left, right) => compareUtf16(left.manifest.holder.principal, right.manifest.holder.principal))); +}; +export const compileCapabilityManifests = (input: CapabilityManifestCompilationInput): readonly CapabilityManifestArtifact[] => { try { return compile(input); } catch { return reject("invalid compilation input"); } }; + +const assertNoDuplicateKeys = (text: string): void => { + let index = 0; const white = () => { while (/\s/u.test(text[index] ?? "")) index += 1; }; + const string = (): string => { const start = index++; let escaped = false; while (index < text.length) { const char = text[index++]!; if (!escaped && char === "\"") return JSON.parse(text.slice(start, index)) as string; escaped = !escaped && char === "\\"; if (char !== "\\") escaped = false; } return fail("unterminated JSON string"); }; + const value = (): void => { white(); const char = text[index]; if (char === "\"") { string(); return; } if (char === "{") { index += 1; white(); const keys = new Set(); if (text[index] === "}") { index += 1; return; } while (true) { white(); if (text[index] !== "\"") fail("invalid JSON object"); const key = string(); if (keys.has(key)) fail("duplicate JSON key"); keys.add(key); white(); if (text[index++] !== ":") fail("invalid JSON object"); value(); white(); if (text[index] === "}") { index += 1; return; } if (text[index++] !== ",") fail("invalid JSON object"); } } if (char === "[") { index += 1; white(); if (text[index] === "]") { index += 1; return; } while (true) { value(); white(); if (text[index] === "]") { index += 1; return; } if (text[index++] !== ",") fail("invalid JSON array"); } } while (index < text.length && !/[\s,}\]]/u.test(text[index]!)) index += 1; }; + value(); white(); if (index !== text.length) fail("invalid JSON trailing content"); +}; +const wireBytes = (input: readonly number[] | Uint8Array): Uint8Array => { + if (isProxy(input)) fail("bytes must not be a proxy"); + if (typeof input === "object" && input !== null + && (Object.getPrototypeOf(input) === Uint8Array.prototype + || Object.getPrototypeOf(input) === Buffer.prototype)) { + const nativeBytes = input as Uint8Array; + const length = TYPED_ARRAY_LENGTH.call(nativeBytes); + const output = new Uint8Array(length); + TYPED_ARRAY_SET.call(output, nativeBytes); + return output; + } + return Uint8Array.from(array(input, "bytes").map((byte) => { if (!Number.isInteger(byte)) fail("bytes must be octets"); const octet = byte as number; if (octet < 0 || octet > 255) fail("bytes must be octets"); return octet; })); +}; +export const parseCapabilityManifest = (input: readonly number[] | Uint8Array): CapabilityManifest => { + try { + const raw = wireBytes(input); const text = new TextDecoder("utf-8", { fatal: true }).decode(raw); + if (text.length === 0 || !text.endsWith("\n") || text.slice(0, -1).includes("\n")) fail("bytes must have one final LF"); + const encoded = UTF8.encode(text); if (encoded.length !== raw.length || encoded.some((byte, index) => byte !== raw[index])) fail("bytes are not canonical UTF-8"); + assertNoDuplicateKeys(text.slice(0, -1)); let decoded: unknown; try { decoded = JSON.parse(text); } catch { return fail("bytes are not JSON"); } + const manifest = validate(decoded); const canonical = serialize(manifest); if (canonical.length !== raw.length || canonical.some((byte, index) => byte !== raw[index])) fail("bytes are not canonical"); + return manifest; + } catch { return reject("invalid manifest bytes"); } +}; diff --git a/src/world/checkpoint.test.ts b/src/world/checkpoint.test.ts new file mode 100644 index 0000000..746773d --- /dev/null +++ b/src/world/checkpoint.test.ts @@ -0,0 +1,356 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { parseWorldCheckpoint, cloneWorldCheckpoint, WORLD_CHECKPOINT_VERSION, type WorldCheckpoint } from "./checkpoint.js"; +import { compileCapabilityManifests } from "./capabilityManifest.js"; +import { parseWorldSurfaceDefinition } from "../world-surface/index.js"; +import { validWorldSurface } from "../world-surface/definition.test-helper.js"; +import * as worldBarrel from "./index.js"; +import * as rootBarrel from "../index.js"; +import { createWorldActionJournal } from "./actionJournal.js"; +import { parseWorldActionJournalSnapshot } from "./actionJournalSnapshot.js"; +import { createWorldRequestLedger } from "./requestLedger.js"; +import { parseWorldRequestLedgerSnapshot, worldRequestLedgerRecordCodeUnits } from "./requestLedgerSnapshot.js"; +import { encodeWorldActEnvelope } from "./actEnvelope.js"; +import { createWorldActionResultLedger, readWorldActionResultLedger } from "./actionResultLedger.js"; +import { parseWorldActionResultLedgerSnapshot } from "./actionResultLedgerSnapshot.js"; +import { digestDynamicsActionAttempt } from "../dynamics/actionRetention.js"; + +const artifact = "a".repeat(64); +const fingerprint = `sha256:${"b".repeat(64)}`; +const dynamics = () => ({ + version: "simfile.dynamics-snapshot.v1", accepted_action_sequences: { floor: 1, above_floor: [] }, + action_ingress: [], action_ingress_floor: 1, action_ingress_ordinal: 0, next_action_sequence: 1, + next_event_sequence: 1, next_tick: 0, pending_actions: [], provider_state: {}, + provenance: { api_version: "simfile.dynamics-provider.v1", config_sha256: "c".repeat(64), module: "provider", module_sha256: artifact, node_version: "node", numeric_model: "ieee754-binary64", provider_dependencies: {}, provider_id: "provider:id", provider_version: "1", state_schema_version: "1" }, + resolved_action_sequences: { floor: 1, above_floor: [] }, seed: "seed", sim_seconds_per_tick: 1, +}); +const empty = (): Record => ({ + version: WORLD_CHECKPOINT_VERSION, + static: { executed_artifact_sha256: artifact, dynamics_build_receipt_sha256: "d".repeat(64), capability_manifests: [] }, + dynamics: dynamics(), + decisions: { version: "simfile.decision-registry.v1", runId: "run", worldInstanceId: "instance", tokenDigestKeyFingerprint: fingerprint, phase: "open", cutoffTick: null, admissionsClosedTick: null, finalizedTick: null, lastTick: null, nextDecisionSequence: 1, decisions: [] }, + action_journal: { version: "simfile.world-action-journal.v1", closed: false, lanes: [], audits: [], cells: [] }, + request_ledger: { version: "simfile.world-request-ledger.v1", closed: false, record_count: 0, code_units: 0, records: [] }, + action_result_ledger: { version: "simfile.world-action-result-ledger.v1", max_entries: 1, max_principals: 1, issuer: "e".repeat(32), secret: "f".repeat(64), bindings: [], entries: [], pages: [], evicted: [], result_ids: [], receipt_ids: [], decision_ids: [], action_sequences: [], effect_watermarks: [], admitted: 0, previous_action: 0, next_result: 1, next_effect: 1 }, + read_ledger: { version: "simfile.world-read-ledger.v1", max_entries_per_principal: 1, max_principals: 1, lanes: [] }, +}); +const accepted = (value: unknown): WorldCheckpoint => { + const parsed = parseWorldCheckpoint(value); + assert(parsed !== undefined, "positive fixture must be accepted"); + return parsed; +}; +const manifestArtifacts = () => compileCapabilityManifests({ + runId: "run-1", worldInstanceId: "instance-1", world: { id: "pitch" as never }, + surfaceRegistry: parseWorldSurfaceDefinition(validWorldSurface()), grants: [{ + participant: "red", principal: "principal-red", entity: "world://pitch/entity/red" as never, + senses: ["world://pitch/sense/vision" as never], affordances: ["world://pitch/affordance/kick" as never] + }] +}); +const twoManifestArtifacts = (runId = "run-1") => { + const surface = validWorldSurface() as Record; + surface.entities.blue = { address: "entity:blue", dynamics_address: "object:player.blue" }; + return compileCapabilityManifests({ + runId, worldInstanceId: "instance-1", world: { id: "pitch" as never }, + surfaceRegistry: parseWorldSurfaceDefinition(surface), grants: [ + { participant: "red", principal: "principal-red", entity: "world://pitch/entity/red" as never, senses: ["world://pitch/sense/vision" as never], affordances: ["world://pitch/affordance/kick" as never] }, + { participant: "blue", principal: "principal-blue", entity: "world://pitch/entity/blue" as never, senses: ["world://pitch/sense/vision" as never], affordances: ["world://pitch/affordance/kick" as never] }, + ] + }); +}; + +const composed = (): Record => { + const [manifest] = manifestArtifacts(); const identity = (state_version = 0) => ({ run_id: "run-1", world_id: "pitch", world_instance_id: "instance-1", manifest_digest: manifest.digest, state_version }); + const holder = manifest.manifest.holder.entity, affordanceEntry = manifest.manifest.affordances[0]!, affordance = affordanceEntry.address; + const target = affordanceEntry.target_selector.kind === "holder" ? holder : affordanceEntry.target_selector.targets[0]!; + const action = (sequence: number) => { const tick = sequence === 3 ? 1 : 0; return ({ receipt_id: `world-act-${sequence}`, decision_id: `decision-${String(sequence).padStart(12, "0")}`, principal: "principal-red", holder, affordance, target, at_tick: tick, dynamics_sequence: sequence, mechanics_action: "kick", mechanics_actor: "object:player.red", mechanics_target: "object:player.red", lowered_input: {}, identity: identity(tick) }); }; + const receipt = (sequence: number) => { const tick = sequence === 3 ? 1 : 0; return ({ disposition: "queued" as const, receipt_id: `world-act-${sequence}`, decision_id: `decision-${String(sequence).padStart(12, "0")}`, identity: identity(tick), apply_tick: tick }); }; + const journal = createWorldActionJournal(); journal.reservePrincipals(["principal-red"]); + for (let sequence = 1; sequence <= 3; sequence += 1) { journal.audit("principal-red", "queued"); const cell = journal.reserve(receipt(sequence), sequence); cell.persist(action(sequence)); cell.prepareAuthorization(); cell.authorize(); } + journal.terminal({ disposition: "applied", receipt_id: "world-act-1", decision_id: "decision-000000000001", sequence: 1, apply_tick: 0, projection: "not_configured" }); + journal.project({ disposition: "applied", receipt_id: "world-act-1", decision_id: "decision-000000000001", sequence: 1, apply_tick: 0, projection: "projected", effect: { projected: true } }); + journal.terminal({ disposition: "rejected_at_mechanics", receipt_id: "world-act-2", decision_id: "decision-000000000002", sequence: 2, apply_tick: 0, projection: "not_configured" }); + const requestLedger = createWorldRequestLedger(); + for (let sequence = 1; sequence <= 3; sequence += 1) { + const requestId = `request-${sequence}`, bytes = encodeWorldActEnvelope({ request_id: requestId, affordance, target, input: {} }); + const claim = requestLedger.begin({ bytes, authority: { principal: "principal-red", run_id: "run-1", world_id: "pitch", world_instance_id: "instance-1" } }); + if (claim.kind !== "new") throw new Error("request fixture claim failed"); + claim.reservation.prepare({ at_tick: sequence === 3 ? 1 : 0, queued_action: action(sequence), receipt: receipt(sequence) }); claim.reservation.commit(); + } + const resultLedger = createWorldActionResultLedger({ maxEntriesPerPrincipal: 1, maxPrincipals: 1 }); const writer = readWorldActionResultLedger(resultLedger)!; + writer.reserve({ bindings: [{ principal: "principal-red", actor: holder, run_id: "run-1", world_id: "pitch", world_instance_id: "instance-1", manifest_digest: manifest.digest }] }); + writer.append({ principal: "principal-red", result: { version: "simfile.world-action-result.v1", result_id: "world-result-1", receipt_id: "world-act-1", decision_id: "decision-000000000001", actor: holder, action_sequence: 1, apply_tick: 0, status: "applied", caused_effect_ids: [], identity: identity(1) } }); + writer.append({ principal: "principal-red", result: { version: "simfile.world-action-result.v1", result_id: "world-result-2", receipt_id: "world-act-2", decision_id: "decision-000000000002", actor: holder, action_sequence: 2, apply_tick: 0, status: "rejected_at_mechanics", rejection_code: "world_action_rejected", identity: identity(1) } }); + const pendingRecord = action(3); + const pendingAttempt = { act_id: pendingRecord.receipt_id, action: pendingRecord.mechanics_action, + actor: pendingRecord.mechanics_actor, at_tick: pendingRecord.at_tick, input: pendingRecord.lowered_input, + origin: "agentic" as const, principal_id: pendingRecord.principal, target: pendingRecord.mechanics_target }; + return { + version: WORLD_CHECKPOINT_VERSION, + static: { executed_artifact_sha256: artifact, dynamics_build_receipt_sha256: "d".repeat(64), capability_manifests: [manifest] }, + dynamics: { ...dynamics(), next_tick: 1, next_action_sequence: 4, + accepted_action_sequences: { floor: 2, above_floor: [] }, action_ingress_floor: 3, + action_ingress_ordinal: 3, action_ingress: [{ act_id: pendingAttempt.act_id, + at_tick: pendingAttempt.at_tick, attempt_sha256: digestDynamicsActionAttempt(pendingAttempt), + principal_id: pendingAttempt.principal_id, retained_at_tick: pendingAttempt.at_tick, + receipt: { act_id: pendingAttempt.act_id, + apply_tick: pendingAttempt.at_tick, queued: true, sequence: 3 } }], + pending_actions: [{ ...pendingAttempt, sequence: 3 }], + resolved_action_sequences: { floor: 3, above_floor: [] } }, + decisions: { version: "simfile.decision-registry.v1", runId: "run-1", worldInstanceId: "instance-1", tokenDigestKeyFingerprint: fingerprint, phase: "open", cutoffTick: null, admissionsClosedTick: null, finalizedTick: null, lastTick: 0, nextDecisionSequence: 4, decisions: [1, 2, 3].map((sequence) => ({ decisionId: `decision-${String(sequence).padStart(12, "0")}`, principal: "principal-red", status: "consumed", issuedTick: 0, validThroughTick: 10, tokenDigest: `sha256:${String(sequence).repeat(64).slice(0, 64)}` })) }, + action_journal: journal.snapshot(), request_ledger: requestLedger.snapshot(), action_result_ledger: (resultLedger && (resultLedger as object) ? (awaitedResult(resultLedger)) : undefined), + read_ledger: { version: "simfile.world-read-ledger.v1", max_entries_per_principal: 1, max_principals: 1, lanes: [{ principal: "principal-red", last_sequence: 1, evicted_through: 0, records: [{ sequence: 1, operation: "status", principal: "principal-red", result: "denied" }] }] }, + }; +}; +const awaitedResult = (ledger: ReturnType): unknown => readWorldActionResultLedger(ledger)!.exportState(); + +test("parses the minimal detached immutable checkpoint", () => { + const source = empty(); + const checkpoint = accepted(source); + assert.equal(checkpoint.version, WORLD_CHECKPOINT_VERSION); + assert(Object.isFrozen(checkpoint)); + assert(Object.isFrozen(checkpoint.static)); + assert(Object.isFrozen(checkpoint.dynamics)); + assert(Object.isFrozen(checkpoint.dynamics.provider_state)); + assert(Object.isFrozen(checkpoint.dynamics.provenance)); + assert(Object.isFrozen(checkpoint.action_result_ledger)); + source.static = { ...(source.static as object), executed_artifact_sha256: "0".repeat(64) }; + (source.dynamics as Record).provider_state = { changed: true }; + assert.equal(checkpoint.static.executed_artifact_sha256, artifact); + assert.deepEqual(checkpoint.dynamics.provider_state, {}); +}); + +test("accepts a compiler-built mixed pending terminal and evicted-result composition", () => { + const source = composed(); + const checkpoint = accepted(source); + assert.equal(checkpoint.action_journal.cells.length, 3); + assert.equal(checkpoint.action_result_ledger.admitted, 2); + assert.deepEqual(checkpoint.action_result_ledger.entries[0]!.values[0]!.result.status, "rejected_at_mechanics"); + assert.deepEqual(checkpoint.action_result_ledger.evicted[0], ["principal-red", 1]); + const tampered = structuredClone(source) as Record; + tampered.request_ledger.records.pop(); + assert.equal(parseWorldCheckpoint(tampered), undefined); +}); + +test("clone is deterministic and rejects one relation tamper", () => { + const source = accepted(empty()); + assert.deepEqual(cloneWorldCheckpoint(source), source); + const tampered = structuredClone(source) as Record; + tampered.static.executed_artifact_sha256 = "0".repeat(64); + assert.equal(parseWorldCheckpoint(tampered), undefined); +}); + +test("fails closed for hostile root shapes without invoking traps", () => { + const base = empty(); + const accessor = { ...base, get dynamics() { throw new Error("trap"); } }; + assert.equal(parseWorldCheckpoint(accessor), undefined); + const proxy = new Proxy(base, { get() { throw new Error("trap"); } }); + assert.equal(parseWorldCheckpoint(proxy), undefined); + const sparse = empty(); + (sparse.action_journal as Record).cells = new Array(1); + assert.equal(parseWorldCheckpoint(sparse), undefined); + const symbols = empty(); + Object.defineProperty(symbols, Symbol("extra"), { enumerable: true, value: 1 }); + assert.equal(parseWorldCheckpoint(symbols), undefined); +}); + +test("rejects aliases, cycles, thenables, and over-limit outer histories", () => { + const aliased = empty(); + const shared = {}; + (aliased.dynamics as Record).provider_state = shared; + (aliased.request_ledger as Record).records = [shared]; + assert.equal(parseWorldCheckpoint(aliased), undefined); + const cycle = empty(); + (cycle.dynamics as Record).provider_state = cycle.dynamics; + assert.equal(parseWorldCheckpoint(cycle), undefined); + const thenable = empty(); + (thenable.dynamics as Record).provider_state = { then: 1 }; + assert.equal(parseWorldCheckpoint(thenable), undefined); + const over = empty(); + (over.action_journal as Record).cells = new Array(10001).fill(undefined); + assert.equal(parseWorldCheckpoint(over), undefined); +}); + +test("accepts a compiler/parser manifest artifact before every identity tamper", () => { + const artifacts = manifestArtifacts(); + assert.equal(artifacts.length, 1); + const source = empty(); (source.static as Record).capability_manifests = artifacts; + (source.decisions as Record).runId = "run-1"; + (source.decisions as Record).worldInstanceId = "instance-1"; + assert(accepted(source)); + for (const field of ["run_id", "world", "holder", "surface", "manifest_digest"] as const) { + const tampered = structuredClone(source) as Record; + const manifest = tampered.static.capability_manifests[0].manifest; + if (field === "run_id") manifest.run_id = "other"; + if (field === "world") manifest.world.id = "other"; + if (field === "holder") manifest.holder.principal = "other"; + if (field === "surface") manifest.surface.registry_digest = "0".repeat(64); + if (field === "manifest_digest") manifest.manifest_digest = "sha256:" + "0".repeat(64); + assert.equal(parseWorldCheckpoint(tampered), undefined, field); + } +}); + +test("rejects a graph that is one level deeper than the advertised cap", () => { + const source = empty(); let value: Record = {}; + for (let index = 0; index < 26; index += 1) value = { child: value }; + (source.dynamics as Record).provider_state = value; + assert.equal(parseWorldCheckpoint(source), undefined); +}); + +test("composes distinct allowed and denied read lanes without cross-lane equality", () => { + const artifacts = twoManifestArtifacts(); assert.equal(artifacts.length, 2); + const red = artifacts.find((artifact) => artifact.manifest.holder.principal === "principal-red")!; + const source = empty(); + (source.static as Record).capability_manifests = artifacts; + (source.decisions as Record).runId = "run-1"; + (source.decisions as Record).worldInstanceId = "instance-1"; + (source.decisions as Record).nextDecisionSequence = 3; + (source.decisions as Record).lastTick = 0; + (source.decisions as Record).decisions = [ + { decisionId: "decision-000000000001", principal: "principal-red", status: "active", issuedTick: 0, validThroughTick: 10, tokenDigest: `sha256:${"1".repeat(64)}` }, + { decisionId: "decision-000000000002", principal: "principal-blue", status: "active", issuedTick: 0, validThroughTick: 10, tokenDigest: `sha256:${"2".repeat(64)}` }, + ]; + (source.read_ledger as Record).max_entries_per_principal = 2; + (source.read_ledger as Record).max_principals = 2; + (source.read_ledger as Record).lanes = [ + { principal: "principal-red", last_sequence: 1, evicted_through: 0, records: [{ sequence: 1, operation: "status", principal: "principal-red", decision_id: "decision-000000000001", state_version: 0, result: "allowed", identity: { run_id: "run-1", world_id: "pitch", world_instance_id: "instance-1", manifest_digest: red.digest, state_version: 0 } }] }, + { principal: "principal-blue", last_sequence: 1, evicted_through: 0, records: [{ sequence: 1, operation: "status", principal: "principal-blue", result: "denied" }] }, + ]; + assert(accepted(source)); + const tampered = structuredClone(source) as Record; + tampered.read_ledger.lanes[0].records[0].identity.manifest_digest = artifacts.find((artifact) => artifact !== red)!.digest; + assert.equal(parseWorldCheckpoint(tampered), undefined); +}); + +test("requires one canonical manifest identity and parser order", () => { + const ordered = twoManifestArtifacts(); + const source = empty(); (source.static as Record).capability_manifests = [...ordered].reverse(); + (source.decisions as Record).runId = "run-1"; + (source.decisions as Record).worldInstanceId = "instance-1"; + assert(accepted(source)); + assert.deepEqual(accepted(source).static.capability_manifests.map((item) => item.manifest.holder.principal), ["principal-blue", "principal-red"]); + const differentRun = twoManifestArtifacts("run-2").find((item) => item.manifest.holder.principal === "principal-blue")!; + const mismatch = empty(); (mismatch.static as Record).capability_manifests = [ordered[0], differentRun]; + assert.equal(parseWorldCheckpoint(mismatch), undefined); +}); + +test("rejects a one-over history before inspecting numeric index zero", () => { + const source = empty(); const cells = new Array(10_001); let inspected = 0; + Object.defineProperty(cells, "0", { enumerable: true, get: () => { inspected += 1; throw new Error("index zero inspected"); } }); + (source.action_journal as Record).cells = cells; + assert.equal(parseWorldCheckpoint(source), undefined); + assert.equal(inspected, 0); +}); + +test("proves the C2 relation admission matrix from an accepted baseline", () => { + type Mutable = Record; + type OwnerCheck = (value: Mutable, name: string) => void; + const journalAccepted: OwnerCheck = (value, name) => assert.ok(parseWorldActionJournalSnapshot(value.action_journal), `${name}: journal owner accepted`); + const requestAccepted: OwnerCheck = (value, name) => assert.ok(parseWorldRequestLedgerSnapshot(value.request_ledger), `${name}: request owner accepted`); + const resultAccepted: OwnerCheck = (value, name) => assert.doesNotThrow(() => parseWorldActionResultLedgerSnapshot(value.action_result_ledger), `${name}: B71 result owner accepted`); + const cases: readonly ([string, (value: Mutable) => void] | [string, (value: Mutable) => void, OwnerCheck])[] = [ + ["free lane principal", (v) => { v.action_journal.lanes[0].principal = "principal-blue"; }], + ["free audit principal", (v) => { v.action_journal.audits[0].principal = "principal-blue"; }], + ["holder grant", (v) => { v.action_journal.cells[0].record.holder = "world://pitch/entity/other"; }], + ["affordance grant", (v) => { v.action_journal.cells[0].record.affordance = "world://pitch/affordance/other"; }], + ["target grant", (v) => { v.action_journal.cells[0].record.target = "world://pitch/entity/other"; }], + ["decision status", (v) => { v.decisions.decisions[0].status = "active"; }], + ["decision run closure", (v) => { v.decisions.runId = "other-run"; }], + ["decision instance closure", (v) => { v.decisions.worldInstanceId = "other-instance"; }], + ["decision issued clock", (v) => { v.decisions.decisions[0].issuedTick = 2; }], + ["decision last clock", (v) => { v.decisions.lastTick = 2; }], + ["decision cutoff clock", (v) => { v.decisions.phase = "cutoff"; v.decisions.cutoffTick = 2; }], + ["journal receipt", (v) => { + const cell = v.action_journal.cells[0]; cell.receipt.apply_tick = 1; cell.record.at_tick = 1; + cell.receipt.identity.state_version = 1; cell.record.identity.state_version = 1; + if (cell.terminal !== null) cell.terminal.apply_tick = 1; + }, journalAccepted], + ["journal decision", (v) => { v.action_journal.cells[0].record.decision_id = "decision-000000000003"; }], + ["journal state version", (v) => { + v.action_journal.cells[0].receipt.identity.state_version = 1; + v.action_journal.cells[0].record.identity.state_version = 1; + }, journalAccepted], + ["dynamics lowered input", (v) => { v.dynamics.action_ingress[0].attempt_sha256 = "0".repeat(64); }], + ["dynamics pending join", (v) => { v.dynamics.pending_actions[0].sequence = 1; }], + ["request authority join", (v) => { + const record = v.request_ledger.records[0]; const oldUnits = worldRequestLedgerRecordCodeUnits(record); + record.authority.world_id = "other-world"; + record.receipt.identity.world_id = "other-world"; record.queued_action.identity.world_id = "other-world"; + v.request_ledger.code_units += worldRequestLedgerRecordCodeUnits(record) - oldUnits; + }, requestAccepted], + ["request orphan", (v) => { v.request_ledger.records.pop(); }], + ["pending receipt evidence", (v) => { v.action_result_ledger.receipt_ids[0] = "world-act-3"; }], + ["pending decision evidence", (v) => { v.action_result_ledger.decision_ids[0] = "decision-000000000003"; }], + ["pending sequence evidence", (v) => { v.action_result_ledger.action_sequences[0] = 3; }], + ["retained actor", (v) => { + const actor = "world://pitch/entity/other"; + v.action_result_ledger.bindings[0].actor = actor; v.action_result_ledger.entries[0].values[0].result.actor = actor; + }, resultAccepted], + ["retained rejection code", (v) => { v.action_result_ledger.entries[0].values[0].result.rejection_code = "other_code"; }], + ["retained post state", (v) => { v.action_result_ledger.entries[0].values[0].result.identity.state_version = 0; }], + ["unsafe successor", (v) => { v.action_journal.cells[0].terminal.apply_tick = Number.MAX_SAFE_INTEGER; }], + ]; + for (const [name, alter, ownerCheck] of cases) { + const source = composed(); assert(accepted(source), `${name}: baseline`); + const tampered = structuredClone(source) as Mutable; alter(tampered); + ownerCheck?.(tampered, name); + assert.equal(parseWorldCheckpoint(tampered), undefined, name); + } +}); + +test("rejects every bounded owner one-over before index zero", () => { + type Mutable = Record; + const cases: readonly [(value: Mutable) => any, (value: Mutable, array: any[]) => void][] = [ + [(v) => v.action_journal, (v, a) => { v.cells = a; }], + [(v) => v.dynamics, (v, a) => { v.accepted_action_sequences.above_floor = a; }], + [(v) => v.request_ledger, (v, a) => { v.records = a; }], + [(v) => v.read_ledger, (v, a) => { v.lanes = a; }], + ]; + for (const [section, install] of cases) { + const source = empty() as any, target = section(source), limit = install === cases[3]![1] ? 2 : 10_001, over = new Array(limit); + let inspected = 0; Object.defineProperty(over, "0", { enumerable: true, get: () => { inspected += 1; throw new Error("index zero inspected"); } }); install(target, over); + assert.equal(parseWorldCheckpoint(source), undefined); assert.equal(inspected, 0); + } +}); + +test("accepts zero-effect results and independently checks global evidence sets", () => { + const source = composed(); assert(accepted(source)); + const cases: readonly [(value: any) => void, string][] = [ + [(v) => { v.action_result_ledger.receipt_ids[1] = "world-act-3"; }, "receipt set"], + [(v) => { v.action_result_ledger.decision_ids[1] = "decision-000000000003"; }, "decision set"], + [(v) => { v.action_result_ledger.action_sequences[1] = 3; }, "sequence set"], + ]; + for (const [alter, name] of cases) { const tampered = structuredClone(source) as any; alter(tampered); assert.equal(parseWorldCheckpoint(tampered), undefined, name); } +}); + +test("accepts legal 10,000-entry dynamics and read relation maxima", () => { + const [manifest] = manifestArtifacts(); const source = empty() as any; + source.static.capability_manifests = [manifest]; source.decisions.runId = "run-1"; source.decisions.worldInstanceId = "instance-1"; + source.decisions.nextDecisionSequence = 10_001; source.decisions.lastTick = 0; + source.decisions.decisions = Array.from({ length: 10_000 }, (_, index) => ({ + decisionId: `decision-${String(index + 1).padStart(12, "0")}`, principal: "principal-red", status: "consumed", issuedTick: 0, validThroughTick: 0, + tokenDigest: `sha256:${index.toString(16).padStart(64, "0")}`, + })); + source.read_ledger.max_entries_per_principal = 10_000; source.read_ledger.lanes = [{ principal: "principal-red", last_sequence: 10_000, evicted_through: 0, + records: Array.from({ length: 10_000 }, (_, index) => ({ sequence: index + 1, operation: "status", principal: "principal-red", decision_id: "decision-000000000001", + state_version: 0, result: "allowed", identity: { run_id: "run-1", world_id: "pitch", world_instance_id: "instance-1", manifest_digest: manifest.digest, state_version: 0 } })) }]; + assert(accepted(source)); +}); + +test("rejects inherited, dangerous, exotic, and thenable graphs without mutation", () => { + const hostile: readonly [string, () => Record][] = [ + ["inherited", () => Object.assign(Object.create({ inherited: 1 }), empty())], + ["dangerous", () => { const value = { ...empty(), dynamics: { ...(empty().dynamics as object) } }; Object.defineProperty(value.dynamics, "__proto__", { enumerable: true, value: 1 }); return value; }], + ["exotic", () => ({ ...empty(), dynamics: Object.assign(new Date(), empty().dynamics) })], + ["thenable", () => ({ ...empty(), dynamics: Object.assign({}, empty().dynamics, { provider_state: { then: 1 } }) })], + ]; + for (const [name, make] of hostile) { + const source = make(), before = structuredClone(source); assert.equal(parseWorldCheckpoint(source), undefined, name); assert.equal(JSON.stringify(source), JSON.stringify(before), name); + } +}); + +test("exports checkpoint values without exposing host checkpoint authority", () => { + assert.equal(worldBarrel.parseWorldCheckpoint, parseWorldCheckpoint); + assert.equal(rootBarrel.parseWorldCheckpoint, parseWorldCheckpoint); + assert.equal(Object.hasOwn(worldBarrel, "registerWorldRuntimeCheckpointCoordinator"), false); + assert.equal(Object.hasOwn(rootBarrel, "restoreWorldRuntimeCheckpoint"), false); +}); diff --git a/src/world/checkpoint.ts b/src/world/checkpoint.ts new file mode 100644 index 0000000..df6c742 --- /dev/null +++ b/src/world/checkpoint.ts @@ -0,0 +1,30 @@ +import { copyWorldCheckpointSnapshot, type WorldCheckpointSnapshot, type WorldCheckpointStatic } from "./checkpointSnapshot.js"; +import { validateWorldCheckpointRelations } from "./checkpointRelations.js"; + +export const WORLD_CHECKPOINT_VERSION = "simfile.world-checkpoint.v1" as const; +export interface WorldCheckpoint extends WorldCheckpointSnapshot { + readonly version: typeof WORLD_CHECKPOINT_VERSION; +} +export type { WorldCheckpointStatic } from "./checkpointSnapshot.js"; + +const deepFreeze = (value: T, seen = new Set()): T => { + if (value === null || typeof value !== "object" || seen.has(value as object)) return value; + seen.add(value as object); + for (const child of Object.values(value as Record)) deepFreeze(child, seen); + return Object.freeze(value); +}; +const compose = (snapshot: WorldCheckpointSnapshot): WorldCheckpoint => deepFreeze({ version: WORLD_CHECKPOINT_VERSION, ...snapshot }); + +export const parseWorldCheckpoint = (input: unknown): WorldCheckpoint | undefined => { + try { + const snapshot = copyWorldCheckpointSnapshot(input); + if (snapshot === undefined || !validateWorldCheckpointRelations(snapshot)) return undefined; + return compose(snapshot); + } catch { return undefined; } +}; + +export const cloneWorldCheckpoint = (input: WorldCheckpoint): WorldCheckpoint => { + const parsed = parseWorldCheckpoint(input); + if (parsed === undefined) throw new TypeError("invalid world checkpoint"); + return parsed; +}; diff --git a/src/world/checkpointDynamicsSnapshot.ts b/src/world/checkpointDynamicsSnapshot.ts new file mode 100644 index 0000000..88daa15 --- /dev/null +++ b/src/world/checkpointDynamicsSnapshot.ts @@ -0,0 +1,78 @@ +import { types } from "node:util"; +import { cloneDynamicsJson } from "../dynamics/canonicalJson.js"; +import { DYNAMICS_ACTION_RETENTION_LIMITS, DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { parseDynamicsSessionSnapshot } from "../dynamics/snapshotValidation.js"; +import type { DynamicsJsonValue, DynamicsSessionSnapshot } from "../dynamics/types.js"; + +const dangerous = new Set(["__proto__", "constructor", "prototype"]); +const index = /^(?:0|[1-9][0-9]*)$/u; +const record = (value: unknown): Record | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value) || types.isProxy(value as object)) return undefined; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return undefined; + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== "string" || dangerous.has(key))) return undefined; + const output: Record = Object.create(null); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) return undefined; + output[key as string] = descriptor.value; + } + return output; +}; +const array = (value: unknown, maximum: number): readonly unknown[] | undefined => { + if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const length = Object.getOwnPropertyDescriptor(value, "length"); + if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || length.value < 0 || length.value > maximum) return undefined; + const keys = Reflect.ownKeys(value); + if (keys.length !== length.value + 1 || keys.some((key) => typeof key !== "string" || (key !== "length" && (!index.test(key) || Number(key) >= length.value)))) return undefined; + const output: unknown[] = []; + for (let offset = 0; offset < length.value; offset += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(offset)); + if (!descriptor?.enumerable || !("value" in descriptor)) return undefined; + output.push(descriptor.value); + } + return output; +}; +const copyJson = (value: unknown, seen: Set): DynamicsJsonValue | undefined => { + if (value === null || typeof value !== "object") return typeof value === "function" || typeof value === "symbol" || typeof value === "undefined" ? undefined : value as DynamicsJsonValue; + if (types.isProxy(value) || seen.has(value as object)) return undefined; + seen.add(value as object); + const values = Array.isArray(value) ? array(value, DYNAMICS_LIMITS.retained_action_records) : undefined; + if (values !== undefined) { + const output = values.map((entry) => copyJson(entry, seen)); + if (output.some((entry) => entry === undefined)) return undefined; + return output as DynamicsJsonValue[]; + } + const source = record(value); + if (source === undefined) return undefined; + const output: Record = Object.create(null); + for (const [key, child] of Object.entries(source)) { + const copied = copyJson(child, seen); + if (copied === undefined) return undefined; + output[key] = copied; + } + return output; +}; +const copySection = (value: unknown, maximum: number): unknown[] | undefined => { + const values = array(value, maximum); + if (values === undefined) return undefined; + const seen = new Set(); + const output = values.map((entry) => copyJson(entry, seen)); + return output.some((entry) => entry === undefined) ? undefined : output; +}; + +export const copyCheckpointDynamicsSnapshot = (input: unknown): DynamicsSessionSnapshot | undefined => { + try { + const source = record(input); + if (source === undefined || source.version !== "simfile.dynamics-snapshot.v1") return undefined; + const accepted = copyJson(source.accepted_action_sequences, new Set()); + const ingress = copySection(source.action_ingress, DYNAMICS_ACTION_RETENTION_LIMITS.records); + const pending = copySection(source.pending_actions, DYNAMICS_LIMITS.actions_per_tick); + const resolved = copyJson(source.resolved_action_sequences, new Set()); + const provider = cloneDynamicsJson(copyJson(source.provider_state, new Set()), "checkpoint dynamics provider_state"); + if (accepted === undefined || ingress === undefined || pending === undefined || resolved === undefined) return undefined; + const copy = { ...source, accepted_action_sequences: accepted, action_ingress: ingress, pending_actions: pending, resolved_action_sequences: resolved, provider_state: provider }; + return Object.freeze(parseDynamicsSessionSnapshot(copy)); + } catch { return undefined; } +}; diff --git a/src/world/checkpointRelations.ts b/src/world/checkpointRelations.ts new file mode 100644 index 0000000..c666aae --- /dev/null +++ b/src/world/checkpointRelations.ts @@ -0,0 +1,167 @@ +import { canonicalDynamicsJson } from "../dynamics/canonicalJson.js"; +import { digestDynamicsActionAttempt } from "../dynamics/actionRetention.js"; +import type { DynamicsSessionSnapshot } from "../dynamics/types.js"; +import type { CapabilityManifestArtifact } from "./capabilityManifest.js"; +import type { DecisionRegistrySnapshot } from "./decisionRegistrySnapshot.js"; +import type { WorldActionJournalSnapshot } from "./actionJournalSnapshot.js"; +import type { WorldRequestLedgerSnapshot } from "./requestLedgerSnapshot.js"; +import type { LedgerSnapshotState } from "./actionResultLedger.js"; +import type { WorldReadLedgerSnapshot } from "./readLedgerSnapshot.js"; +import type { WorldCheckpointStatic, WorldCheckpointSnapshot } from "./checkpointSnapshot.js"; + +type Identity = { readonly run_id: string; readonly world_id: string; readonly world_instance_id: string; readonly manifest_digest: string; readonly state_version: number }; +type Manifests = { readonly digest: Map; readonly principal: Map }; +const indexes = (items: readonly CapabilityManifestArtifact[]): Manifests => ({ + digest: new Map(items.map((item) => [item.digest, item])), principal: new Map(items.map((item) => [item.manifest.holder.principal, item])), +}); +const sameJson = (left: unknown, right: unknown): boolean => canonicalDynamicsJson(left) === canonicalDynamicsJson(right); +const sameStatic = (left: Identity, right: Identity): boolean => left.run_id === right.run_id && left.world_id === right.world_id + && left.world_instance_id === right.world_instance_id && left.manifest_digest === right.manifest_digest; +const tick = (value: number | null | undefined, frontier: number): boolean => value === null || value === undefined || (Number.isSafeInteger(value) && value >= 0 && value <= frontier); +const successor = (value: number): number | undefined => Number.isSafeInteger(value) && value >= 0 && value < Number.MAX_SAFE_INTEGER ? value + 1 : undefined; + +const staticRelations = (value: WorldCheckpointStatic, dynamics: DynamicsSessionSnapshot, decisions: DecisionRegistrySnapshot, + journal: WorldActionJournalSnapshot, requests: WorldRequestLedgerSnapshot, results: LedgerSnapshotState, reads: WorldReadLedgerSnapshot): boolean => { + const manifests = indexes(value.capability_manifests); + if (dynamics.provenance.module_sha256 !== value.executed_artifact_sha256 || manifests.digest.size !== value.capability_manifests.length || manifests.principal.size !== value.capability_manifests.length) return false; + const first = value.capability_manifests[0]?.manifest; + for (const artifact of value.capability_manifests) { + const manifest = artifact.manifest; + if (artifact.digest !== manifest.manifest_digest || first !== undefined && (manifest.run_id !== first.run_id || manifest.world.id !== first.world.id + || manifest.world.instance_id !== first.world.instance_id || manifest.surface.registry_digest !== first.surface.registry_digest)) return false; + } + const empty = decisions.decisions.length === 0 && journal.lanes.length === 0 && journal.audits.length === 0 && journal.cells.length === 0 + && requests.records.length === 0 && results.bindings.length === 0 && results.entries.length === 0 && results.admitted === 0 && reads.lanes.length === 0; + if (first === undefined) return empty; + if (decisions.runId !== first.run_id || decisions.worldInstanceId !== first.world.instance_id) return false; + const manifestFor = (item: Identity | undefined, principal: string): CapabilityManifestArtifact | undefined => { + const manifest = item === undefined ? undefined : manifests.digest.get(item.manifest_digest); + return manifest !== undefined && manifest.manifest.holder.principal === principal && item !== undefined + && item.run_id === first.run_id && item.world_id === first.world.id && item.world_instance_id === first.world.instance_id ? manifest : undefined; + }; + const frontier = dynamics.next_tick; + if (!Number.isSafeInteger(frontier) || frontier < 0) return false; + for (const decision of decisions.decisions) if (!manifests.principal.has(decision.principal) || !tick(decision.issuedTick, frontier) + || decision.validThroughTick < decision.issuedTick) return false; + if (!tick(decisions.lastTick, frontier) || !tick(decisions.cutoffTick, frontier) || !tick(decisions.admissionsClosedTick, frontier) || !tick(decisions.finalizedTick, frontier)) return false; + for (const lane of journal.lanes) if (!manifests.principal.has(lane.principal)) return false; + for (const audit of journal.audits) if (!manifests.principal.has(audit.principal)) return false; + for (const cell of journal.cells) if (manifestFor(cell.record.identity, cell.record.principal) === undefined || manifestFor(cell.receipt.identity, cell.record.principal) === undefined + || cell.record.identity.state_version !== cell.record.at_tick || cell.receipt.identity.state_version !== cell.receipt.apply_tick || !tick(cell.record.at_tick, frontier) || !tick(cell.receipt.apply_tick, frontier)) return false; + for (const request of requests.records) if (manifestFor(request.receipt.identity, request.authority.principal) === undefined + || request.authority.run_id !== request.receipt.identity.run_id || request.authority.world_id !== request.receipt.identity.world_id || request.authority.world_instance_id !== request.receipt.identity.world_instance_id + || !tick(request.at_tick, frontier)) return false; + for (const lane of reads.lanes) { + if (!manifests.principal.has(lane.principal)) return false; + for (const record of lane.records) { + if (record.principal !== lane.principal || record.state_version !== undefined && !tick(record.state_version, frontier)) return false; + if (record.result === "allowed" && (record.identity === undefined || record.decision_id === undefined || manifestFor(record.identity, record.principal) === undefined)) return false; + } + } + for (const binding of results.bindings) { + const manifest = manifests.digest.get(binding.manifest_digest); + if (manifest === undefined || manifest.manifest.holder.principal !== binding.principal || manifest.manifest.holder.entity !== binding.actor + || binding.run_id !== first.run_id || binding.world_id !== first.world.id || binding.world_instance_id !== first.world.instance_id) return false; + } + for (const entry of results.entries) for (const retained of entry.values) if (!tick(retained.result.identity.state_version, frontier)) return false; + return true; +}; + +const manifestRelations = (manifests: Manifests, journal: WorldActionJournalSnapshot): boolean => { + const grants = new Map | undefined }>>(); + for (const [principal, artifact] of manifests.principal) { + const affordances = new Map | undefined }>(); + for (const item of artifact.manifest.affordances) affordances.set(item.address, { holder: artifact.manifest.holder.entity, targets: item.target_selector.kind === "holder" ? undefined : new Set(item.target_selector.targets) }); + grants.set(principal, affordances); + } + for (const cell of journal.cells) { + const grant = grants.get(cell.record.principal)?.get(cell.record.affordance); + if (grant === undefined || cell.record.holder !== grant.holder || (grant.targets === undefined ? cell.record.target !== grant.holder : !grant.targets.has(cell.record.target))) return false; + } + return true; +}; + +const dynamicsRelations = (dynamics: DynamicsSessionSnapshot, journal: WorldActionJournalSnapshot): boolean => { + const ingress = new Map(); + for (const item of dynamics.action_ingress) if (item.receipt.queued && item.receipt.sequence !== undefined) ingress.set(item.receipt.sequence, item); + const cells = new Map(journal.cells.map((cell) => [cell.sequence, cell])); + const pending = new Set(dynamics.pending_actions.map((item) => item.sequence)); + const contains = (watermark: DynamicsSessionSnapshot["resolved_action_sequences"], sequence: number): boolean => + sequence < watermark.floor || watermark.above_floor.includes(sequence); + for (const [sequence, item] of ingress) { + const cell = cells.get(sequence); + if (cell === undefined || cell.receipt.receipt_id !== item.receipt.act_id || cell.record.principal !== item.principal_id + || cell.record.at_tick !== item.at_tick || cell.receipt.apply_tick !== item.receipt.apply_tick + || item.attempt_sha256 !== digestDynamicsActionAttempt({ + act_id: cell.receipt.receipt_id, action: cell.record.mechanics_action, + actor: cell.record.mechanics_actor, at_tick: cell.record.at_tick, + input: cell.record.lowered_input, origin: "agentic", principal_id: cell.record.principal, + target: cell.record.mechanics_target + })) return false; + } + for (const cell of journal.cells) { + const resolved = contains(dynamics.resolved_action_sequences, cell.sequence); + const accepted = contains(dynamics.accepted_action_sequences, cell.sequence); + if (pending.has(cell.sequence) !== (cell.state === "authorized") + || resolved !== (cell.state === "terminal") + || accepted !== (cell.terminal?.disposition === "applied")) return false; + } + return [...pending].every((sequence) => ingress.has(sequence)); +}; + +const requestRelations = (requests: WorldRequestLedgerSnapshot, journal: WorldActionJournalSnapshot): boolean => { + const bySequence = new Map(journal.cells.map((cell) => [cell.sequence, cell])); + const seen = new Set(); + for (const request of requests.records) { + const cell = bySequence.get(request.queued_action.dynamics_sequence); + if (cell === undefined || seen.has(request.queued_action.dynamics_sequence) || request.at_tick !== cell.record.at_tick || request.receipt.receipt_id !== cell.receipt.receipt_id || request.receipt.decision_id !== cell.receipt.decision_id + || request.receipt.apply_tick !== cell.receipt.apply_tick || request.queued_action.receipt_id !== cell.record.receipt_id || request.queued_action.decision_id !== cell.record.decision_id + || request.queued_action.principal !== cell.record.principal || request.queued_action.holder !== cell.record.holder || request.queued_action.affordance !== cell.record.affordance + || request.queued_action.target !== cell.record.target || request.queued_action.at_tick !== cell.record.at_tick || request.queued_action.mechanics_action !== cell.record.mechanics_action + || request.queued_action.mechanics_actor !== cell.record.mechanics_actor || request.queued_action.mechanics_target !== cell.record.mechanics_target || !sameJson(request.queued_action.lowered_input, cell.record.lowered_input) + || !sameStatic(request.queued_action.identity, cell.record.identity) || request.queued_action.identity.state_version !== cell.record.identity.state_version + || !sameStatic(request.receipt.identity, cell.receipt.identity)) return false; + seen.add(request.queued_action.dynamics_sequence); + } + return seen.size === journal.cells.length; +}; + +const resultRelations = (results: LedgerSnapshotState, journal: WorldActionJournalSnapshot, manifests: Manifests): boolean => { + const terminals = journal.cells.filter((cell) => cell.terminal !== null), terminalReceipts = new Set(terminals.map((cell) => cell.receipt.receipt_id)); + const terminalDecisions = new Set(terminals.map((cell) => cell.terminal!.decision_id)), terminalActions = new Set(terminals.map((cell) => cell.sequence)); + const exact = (values: readonly T[], expected: Set): boolean => values.length === expected.size && new Set(values).size === values.length && values.every((value) => expected.has(value)); + if (!exact(results.receipt_ids, terminalReceipts) || !exact(results.decision_ids, terminalDecisions) || !exact(results.action_sequences, terminalActions)) return false; + const terminalsByReceipt = new Map(terminals.map((cell) => [cell.receipt.receipt_id, cell])); + const retained = new Map(); + const owners = new Map(results.bindings.map((binding) => [binding.principal, binding])); + for (const entry of results.entries) for (const value of entry.values) { + const result = value.result, cell = terminalsByReceipt.get(result.receipt_id), owner = owners.get(entry.principal), manifest = manifests.principal.get(entry.principal), next = successor(cell?.terminal?.apply_tick ?? -1); + if (cell === undefined || owner === undefined || manifest === undefined || retained.has(result.receipt_id) || result.actor !== owner.actor || result.actor !== manifest.manifest.holder.entity + || result.decision_id !== cell.terminal!.decision_id || result.action_sequence !== cell.sequence || result.apply_tick !== cell.terminal!.apply_tick || next === undefined + || result.identity.state_version !== next || !sameStatic(result.identity, cell.record.identity) || (result.status === "applied" ? cell.terminal!.disposition !== "applied" : cell.terminal!.disposition === "applied") + || result.status === "rejected_at_mechanics" && result.rejection_code !== (cell.terminal!.public_code ?? "world_action_rejected")) return false; + retained.set(result.receipt_id, result); + } + for (const cell of journal.cells) if (cell.state === "authorized" && (results.receipt_ids.includes(cell.receipt.receipt_id) || results.decision_ids.includes(cell.record.decision_id) + || results.action_sequences.includes(cell.sequence) || retained.has(cell.receipt.receipt_id))) return false; + return true; +}; + +const decisionRelations = (decisions: DecisionRegistrySnapshot, journal: WorldActionJournalSnapshot, reads: WorldReadLedgerSnapshot): boolean => { + const known = new Map(decisions.decisions.map((decision) => [decision.decisionId, decision])); + for (const cell of journal.cells) { const decision = known.get(cell.record.decision_id); if (decision === undefined || decision.principal !== cell.record.principal || decision.status !== "consumed") return false; } + for (const lane of reads.lanes) for (const record of lane.records) if (record.result === "allowed") { + const decision = record.decision_id === undefined ? undefined : known.get(record.decision_id); + if (decision === undefined || decision.principal !== record.principal || record.state_version === undefined || record.state_version < decision.issuedTick || record.state_version > decision.validThroughTick) return false; + } + return true; +}; + +export const validateWorldCheckpointRelations = (checkpoint: WorldCheckpointSnapshot): boolean => { + try { + const { static: value, dynamics, decisions, action_journal: journal, request_ledger: requests, action_result_ledger: results, read_ledger: reads } = checkpoint; + const manifests = indexes(value.capability_manifests); + return staticRelations(value, dynamics, decisions, journal, requests, results, reads) && manifestRelations(manifests, journal) + && dynamicsRelations(dynamics, journal) && requestRelations(requests, journal) && resultRelations(results, journal, manifests) && decisionRelations(decisions, journal, reads); + } catch { return false; } +}; diff --git a/src/world/checkpointRestore.test.ts b/src/world/checkpointRestore.test.ts new file mode 100644 index 0000000..1c6fa3b --- /dev/null +++ b/src/world/checkpointRestore.test.ts @@ -0,0 +1,359 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { compileCapabilityManifests } from "./capabilityManifest.js"; +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import { parseWorldCheckpoint } from "./checkpoint.js"; +import { readWorldRuntimeCheckpointCoordinator } from "./checkpointRuntime.js"; +import { createDecisionRegistryForTesting } from "./decisionRegistry.js"; +import { issueBoundWorldGrants } from "./grantAttestation.js"; +import { createWorldReadLedger } from "./ledger.js"; +import { createRestoredWorldRuntime, createWorldRuntime, type CreateWorldRuntimeInput } from "./runtime.js"; +import { runtimeActEnvelope, runtimeFixtureWithHooks, type RuntimeFixtureHooks } from "./runtime.test-helper.js"; +import * as packageBarrel from "../index.js"; +import * as worldBarrel from "./index.js"; + +const receiptSha = "2".repeat(64); +const identity = { + runId: "run-1", + worldInstanceId: "instance-1", + buildReceiptSha256: receiptSha, + decisionValidThroughTick: 4, +}; +const resultRequest = Object.freeze({ + version: "simfile.world-action-result-page-request.v1" as const, + limit: 10, +}); +const stepForActions = (input: unknown) => { + const value = input as { readonly tick: number; readonly actions: readonly { readonly sequence: number }[] }; + return { + tick: value.tick, + events: [], + action_results: value.actions.map(({ sequence }) => ({ accepted: true, sequence })), + }; +}; + +const sourceFixture = (step = stepForActions) => { + const fixture = runtimeFixtureWithHooks({ step }, false, identity); + const runtime = createWorldRuntime({ + dynamics: fixture.dynamics, + surfaceRegistry: fixture.surfaceRegistry, + capabilityManifests: fixture.capabilityManifests, + boundGrants: fixture.boundGrants, + decisionRegistry: fixture.decisionRegistry, + readLedger: fixture.readLedger, + }); + const coordinator = readWorldRuntimeCheckpointCoordinator(runtime); + assert.ok(coordinator); + return { ...fixture, runtime, coordinator }; +}; + +const freshTarget = ( + failRestore?: () => boolean, + targetIdentity: NonNullable[2]> = identity, + snapshot?: RuntimeFixtureHooks["snapshot"], +) => { + const fixture = runtimeFixtureWithHooks({ step: stepForActions, failRestore, snapshot }, false, targetIdentity); + const decisionRegistry = createDecisionRegistryForTesting({ + runId: targetIdentity.runId, + worldInstanceId: targetIdentity.worldInstanceId, + tokenDigestKey: new Uint8Array(32).fill(7), + }, { randomBytes: () => new Uint8Array(32).fill(71) }); + const readLedger = createWorldReadLedger({ maxEntriesPerPrincipal: 20 }); + const input: CreateWorldRuntimeInput = { + dynamics: fixture.dynamics, + surfaceRegistry: fixture.surfaceRegistry, + capabilityManifests: fixture.capabilityManifests, + boundGrants: fixture.boundGrants, + decisionRegistry, + readLedger, + }; + return { ...fixture, input, decisionRegistry, readLedger }; +}; + +const attemptNestedConstruction = (target: ReturnType, checkpoint: unknown) => { + let published = 0; + let rejected = 0; + for (const construct of [ + () => createWorldRuntime(target.input), + () => createRestoredWorldRuntime(target.input, checkpoint), + ]) { + try { construct(); published += 1; } catch { rejected += 1; } + } + return { published, rejected }; +}; + +const populatedSource = () => { + const source = sourceFixture(); + const red = { principal: "principal-red", decisionToken: source.red.token }; + source.runtime.status(red); + const envelope = runtimeActEnvelope("restore-request", { + affordance: "world://pitch/affordance/kick", + target: "world://pitch/entity/ball", + input: { force: 1 }, + }); + const receipt = source.runtime.act(red, envelope); + assert.equal(receipt.disposition, "queued"); + assert.deepEqual(readWorldRuntimeClockAuthority(source.runtime)!.stepDynamics(), { + tick: 0, + action_results: 1, + events: 0, + }); + const next = source.decisionRegistry.mint({ principal: "principal-red", issuedTick: 1, validThroughTick: 4 }); + source.runtime.status({ principal: "principal-red", decisionToken: next.token }); + const resultPage = source.runtime.ledger(red, resultRequest); + const checkpoint = source.coordinator.capture(); + return { source, checkpoint, envelope, receipt, next, resultPage }; +}; + +test("restores fresh owners exactly without advancing a tick and continues every ledger", () => { + const { source, checkpoint, envelope, receipt, next, resultPage } = populatedSource(); + const target = freshTarget(); + const beforeCalls = target.dynamicsCalls(); + const runtime = createRestoredWorldRuntime(target.input, checkpoint); + assert.equal(target.dynamicsCalls(), beforeCalls); + assert.equal(target.restoreCalls(), 1); + assert.equal(target.dynamics.nextTick, checkpoint.dynamics.next_tick); + assert.deepEqual(target.decisionRegistry.snapshot(), checkpoint.decisions); + assert.deepEqual(target.readLedger.read("principal-red", {}), source.readLedger.read("principal-red", {})); + assert.deepEqual(readWorldRuntimeCheckpointCoordinator(runtime)!.capture(), checkpoint); + assert.deepEqual(runtime.act({ principal: "principal-red", decisionToken: source.red.token }, envelope), receipt); + assert.deepEqual(runtime.ledger({ principal: "principal-red", decisionToken: source.red.token }, resultRequest), resultPage); + assert.deepEqual(runtime.ledger({ principal: "principal-red", decisionToken: source.red.token }, { + ...resultRequest, + result_after: resultPage.next_result_after, + }), { identity: resultPage.identity, results: [], next_result_after: resultPage.next_result_after }); + for (const operation of [ + () => runtime.status({ principal: "principal-red", decisionToken: source.red.token }), + () => runtime.capabilities({ principal: "principal-red", decisionToken: source.red.token }), + () => runtime.observe({ principal: "principal-red", decisionToken: source.red.token }, { sense: "world://pitch/sense/vision" }), + () => runtime.affordances({ principal: "principal-red", decisionToken: source.red.token }), + () => runtime.ledger({ principal: "principal-red", decisionToken: source.red.token }, {}), + ]) assert.throws(operation); + assert.deepEqual(runtime.act({ principal: "principal-red", decisionToken: source.red.token }, runtimeActEnvelope("restore-other", { + affordance: "world://pitch/affordance/kick", + target: "world://pitch/entity/ball", + input: { force: 2 }, + })), { disposition: "rejected_at_ingress", code: "world_action_denied", reason: "decision_token_consumed" }); + assert.deepEqual( + runtime.status({ principal: "principal-red", decisionToken: next.token }), + source.runtime.status({ principal: "principal-red", decisionToken: next.token }), + ); + assert.deepEqual(readWorldRuntimeClockAuthority(runtime)!.stepDynamics(), { tick: 1, action_results: 0, events: 0 }); + assert.equal(target.dynamics.nextTick, 2); + assert.throws(() => createWorldRuntime(target.input)); + assert.equal("createRestoredWorldRuntime" in worldBarrel, false); + assert.equal("createRestoredWorldRuntime" in packageBarrel, false); +}); + +test("restored pending mechanics execute on the exact next tick with source parity", () => { + const source = sourceFixture(); + const envelope = runtimeActEnvelope("pending-restore", { + affordance: "world://pitch/affordance/kick", + target: "world://pitch/entity/ball", + input: { force: 1 }, + }); + assert.equal(source.runtime.act({ principal: "principal-red", decisionToken: source.red.token }, envelope).disposition, "queued"); + const checkpoint = source.coordinator.capture(); + const target = freshTarget(); + const runtime = createRestoredWorldRuntime(target.input, checkpoint); + assert.equal(target.dynamics.nextTick, 0); + assert.deepEqual(readWorldRuntimeCheckpointCoordinator(runtime)!.capture(), checkpoint); + const expected = readWorldRuntimeClockAuthority(source.runtime)!.stepDynamics(); + const actual = readWorldRuntimeClockAuthority(runtime)!.stepDynamics(); + assert.deepEqual(actual, expected); + assert.deepEqual(readWorldRuntimeCheckpointCoordinator(runtime)!.capture(), source.coordinator.capture()); +}); + +test("failed first-owner restore publishes nothing and a completely fresh graph retries", () => { + const { checkpoint } = populatedSource(); + let fail = true; + const target = freshTarget(() => { + if (!fail) return false; + fail = false; + return true; + }); + assert.throws(() => createRestoredWorldRuntime(target.input, checkpoint)); + assert.equal(target.dynamics.nextTick, 0); + assert.equal(target.decisionRegistry.snapshot().decisions.length, 0); + assert.equal(target.readLedger.read("principal-red", {}).records.length, 0); + const retry = freshTarget(); + const runtime = createRestoredWorldRuntime(retry.input, checkpoint); + assert.deepEqual(readWorldRuntimeCheckpointCoordinator(runtime)!.capture(), checkpoint); +}); + +test("initial provider snapshot reentry rejects both nested constructors before outer restoration claims owners", () => { + const { checkpoint } = populatedSource(); + let armed = false; + let attempts = { published: -1, rejected: -1 }; + let target: ReturnType; + target = freshTarget(undefined, identity, () => { + if (!armed) return; + armed = false; + attempts = attemptNestedConstruction(target, checkpoint); + }); + armed = true; + + const runtime = createRestoredWorldRuntime(target.input, checkpoint); + assert.deepEqual(attempts, { published: 0, rejected: 2 }); + assert.equal(target.restoreCalls(), 1); + assert.deepEqual(readWorldRuntimeCheckpointCoordinator(runtime)!.capture(), checkpoint); + assert.throws(() => createWorldRuntime(target.input)); + assert.throws(() => createRestoredWorldRuntime(target.input, checkpoint)); +}); + +test("provider restore reentry cannot publish shared owners and a failed outer restore releases its lease", () => { + const { checkpoint } = populatedSource(); + let target: ReturnType; + let firstRestore = true; + let attempts = { published: -1, rejected: -1 }; + target = freshTarget(() => { + if (!firstRestore) return false; + firstRestore = false; + attempts = attemptNestedConstruction(target, checkpoint); + return true; + }); + + assert.throws(() => createRestoredWorldRuntime(target.input, checkpoint)); + assert.deepEqual(attempts, { published: 0, rejected: 2 }); + assert.equal(target.restoreCalls(), 2); + assert.equal(target.dynamics.nextTick, 0); + assert.equal(target.decisionRegistry.snapshot().decisions.length, 0); + assert.equal(target.readLedger.read("principal-red", {}).records.length, 0); + assert.ok(createWorldRuntime(target.input)); + assert.throws(() => createWorldRuntime(target.input)); +}); + +test("final recapture snapshot reentry rejects both nested constructors after permanent owner claim", () => { + const { checkpoint } = populatedSource(); + let afterRestore = false; + let snapshotsAfterRestore = 0; + let attempts = { published: -1, rejected: -1 }; + let target: ReturnType; + target = freshTarget(() => { afterRestore = true; return false; }, identity, () => { + if (!afterRestore) return; + snapshotsAfterRestore += 1; + if (snapshotsAfterRestore === 3) attempts = attemptNestedConstruction(target, checkpoint); + }); + + const runtime = createRestoredWorldRuntime(target.input, checkpoint); + assert.equal(snapshotsAfterRestore, 3); + assert.deepEqual(attempts, { published: 0, rejected: 2 }); + assert.deepEqual(readWorldRuntimeCheckpointCoordinator(runtime)!.capture(), checkpoint); + assert.throws(() => createWorldRuntime(target.input)); + assert.throws(() => createRestoredWorldRuntime(target.input, checkpoint)); +}); + +test("host preguards reject static, capacity, principal, and closure substitutions before claims", () => { + const checkpoint = sourceFixture().coordinator.capture(); + const cases: readonly [string, (value: Record) => void][] = [ + ["executed artifact", (value) => { + value.static.executed_artifact_sha256 = "0".repeat(64); + value.dynamics.provenance.module_sha256 = "0".repeat(64); + }], + ["build receipt", (value) => { value.static.dynamics_build_receipt_sha256 = "3".repeat(64); }], + ["result capacity", (value) => { value.action_result_ledger.max_entries += 1; }], + ["read capacity", (value) => { value.read_ledger.max_entries_per_principal += 1; }], + ["journal principals", (value) => { value.action_journal.lanes.pop(); }], + ["closure", (value) => { value.request_ledger.closed = true; }], + ]; + for (const [name, change] of cases) { + const hostile = structuredClone(checkpoint) as Record; + change(hostile); + assert.ok(parseWorldCheckpoint(hostile), `${name} remains a valid C2 value`); + const target = freshTarget(); + assert.throws(() => createRestoredWorldRuntime(target.input, hostile), name); + assert.ok(createWorldRuntime(target.input), `${name} failure did not claim or mutate fresh owners`); + } +}); + +test("artifact, build, surface, manifest, and run substitutions reject before publication", () => { + const checkpoint = sourceFixture().coordinator.capture(); + const cases = [ + ["artifact", { ...identity, moduleSha256: "0".repeat(64) }], + ["build", { ...identity, buildReceiptSha256: "3".repeat(64) }], + ["surface", { ...identity, fixedTargets: ["entity:blue"] as const }], + ["manifest", { ...identity, redAffordances: ["world://pitch/affordance/wait"] as const }], + ["run", { ...identity, runId: "run-2" }], + ] as const; + for (const [name, substituted] of cases) { + const target = freshTarget(undefined, substituted); + assert.throws(() => createRestoredWorldRuntime(target.input, checkpoint), name); + assert.equal(target.dynamics.nextTick, 0); + assert.equal(target.dynamicsCalls(), 0); + assert.equal(target.restoreCalls(), 0); + assert.ok(createWorldRuntime(target.input)); + } +}); + +test("world identity substitution rejects before publication", () => { + const checkpoint = sourceFixture().coordinator.capture(); + const target = freshTarget(); + const boundGrants = issueBoundWorldGrants([ + { participant: "blue", principal: "principal-blue", entity: "world://other/entity/blue", senses: ["world://other/sense/blue-view"], affordances: ["world://other/affordance/wait"] }, + { participant: "red", principal: "principal-red", entity: "world://other/entity/red", senses: ["world://other/sense/vision", "world://other/sense/red-detail"], affordances: ["world://other/affordance/kick", "world://other/affordance/wait"] }, + ] as const as never); + const capabilityManifests = compileCapabilityManifests({ + runId: identity.runId, + worldInstanceId: identity.worldInstanceId, + world: { id: "other" as never }, + surfaceRegistry: target.surfaceRegistry, + grants: boundGrants as never, + }); + const input = { ...target.input, boundGrants, capabilityManifests }; + assert.throws(() => createRestoredWorldRuntime(input, checkpoint)); + assert.equal(target.dynamics.nextTick, 0); + assert.equal(target.restoreCalls(), 0); + assert.ok(createWorldRuntime(input)); +}); + +test("nonfresh dynamics and decision counters reject before claim", () => { + const checkpoint = sourceFixture().coordinator.capture(); + const advanced = freshTarget(); + assert.deepEqual(advanced.dynamics.step(), { tick: 0, events: [], action_results: [] }); + assert.throws(() => createRestoredWorldRuntime(advanced.input, checkpoint)); + assert.equal(advanced.restoreCalls(), 0); + assert.ok(createWorldRuntime(advanced.input)); + + const minted = freshTarget(); + minted.decisionRegistry.mint({ principal: "principal-red", issuedTick: 0, validThroughTick: 4 }); + assert.throws(() => createRestoredWorldRuntime(minted.input, checkpoint)); + assert.equal(minted.restoreCalls(), 0); + assert.ok(createWorldRuntime(minted.input)); +}); + +test("inconsistent retained counters reject before restore and leave the same owners fresh", () => { + const { checkpoint } = populatedSource(); + const hostile = structuredClone(checkpoint) as Record; + hostile.dynamics.next_action_sequence += 1; + assert.equal(parseWorldCheckpoint(hostile), undefined); + const target = freshTarget(); + assert.throws(() => createRestoredWorldRuntime(target.input, hostile)); + assert.equal(target.restoreCalls(), 0); + assert.equal(target.dynamics.nextTick, 0); + assert.ok(createWorldRuntime(target.input)); +}); + +test("hostile checkpoint rejection invokes no traps and leaves the same inputs publishable", () => { + let traps = 0; + const target = freshTarget(); + const hostile = new Proxy({}, { get: () => { traps += 1; throw new Error("trap"); } }); + assert.throws(() => createRestoredWorldRuntime(target.input, hostile)); + assert.equal(traps, 0); + assert.ok(createWorldRuntime(target.input)); +}); + +test("restores closure as mechanics closure without inferring it from decision phase", () => { + const source = sourceFixture((input) => { + const value = input as { readonly tick: number }; + return { tick: value.tick + 1, events: [], action_results: [] }; + }); + assert.throws(() => readWorldRuntimeClockAuthority(source.runtime)!.stepDynamics()); + const checkpoint = source.coordinator.capture(); + assert.equal(checkpoint.action_journal.closed, true); + assert.equal(checkpoint.request_ledger.closed, true); + assert.equal(checkpoint.decisions.phase, "open"); + const runtime = createRestoredWorldRuntime(freshTarget().input, checkpoint); + assert.throws(() => readWorldRuntimeClockAuthority(runtime)!.stepDynamics(), /closed/u); + assert.deepEqual(readWorldRuntimeCheckpointCoordinator(runtime)!.capture(), checkpoint); +}); diff --git a/src/world/checkpointRestore.ts b/src/world/checkpointRestore.ts new file mode 100644 index 0000000..6a1231f --- /dev/null +++ b/src/world/checkpointRestore.ts @@ -0,0 +1,150 @@ +import { isDeepStrictEqual } from "node:util"; + +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import type { DynamicsSession } from "../dynamics/session.js"; +import type { CapabilityManifestArtifact } from "./capabilityManifest.js"; +import { createWorldActionJournal, type WorldActionJournal } from "./actionJournal.js"; +import { createWorldActionResultLedger, readWorldActionResultLedger, type WorldActionResultLedger } from "./actionResultLedger.js"; +import { snapshotWorldActionResultStore } from "./actionResultLedgerSnapshot.js"; +import { WORLD_CHECKPOINT_VERSION, parseWorldCheckpoint, type WorldCheckpoint } from "./checkpoint.js"; +import type { DecisionRegistry } from "./decisionRegistry.js"; +import { readWorldReadLedger, type WorldReadLedger } from "./ledger.js"; +import { createWorldRequestLedger, type WorldRequestLedger } from "./requestLedger.js"; + +const RESULT_ENTRIES = 256; +const RESULT_PRINCIPALS = 256; +const fail = (): never => { throw new Error("world checkpoint restore unavailable"); }; +const sorted = (values: readonly string[]): readonly string[] => [...values].sort(); +const exactPrincipals = (values: readonly string[], expected: readonly string[]): boolean => + isDeepStrictEqual(sorted(values), expected); + +export interface WorldRuntimePrivateStores { + readonly actionJournal: WorldActionJournal; + readonly requestLedger: WorldRequestLedger; + readonly actionResultLedger: WorldActionResultLedger; +} + +interface WorldRuntimeCheckpointSources extends WorldRuntimePrivateStores { + readonly dynamics: DynamicsSession; + readonly capabilityManifests: readonly CapabilityManifestArtifact[]; + readonly decisionRegistry: DecisionRegistry; + readonly readLedger: WorldReadLedger; +} + +export const createWorldRuntimePrivateStores = (): WorldRuntimePrivateStores => Object.freeze({ + actionJournal: createWorldActionJournal(), + requestLedger: createWorldRequestLedger({ max_records: DYNAMICS_LIMITS.retained_action_records - 1 }), + actionResultLedger: createWorldActionResultLedger({ + maxEntriesPerPrincipal: RESULT_ENTRIES, + maxPrincipals: RESULT_PRINCIPALS, + }), +}); + +const capture = (sources: WorldRuntimeCheckpointSources): WorldCheckpoint => parseWorldCheckpoint({ + version: WORLD_CHECKPOINT_VERSION, + static: { + executed_artifact_sha256: sources.dynamics.provenance.module_sha256, + dynamics_build_receipt_sha256: sources.dynamics.buildReceipt.receiptSha256, + capability_manifests: sources.capabilityManifests, + }, + dynamics: sources.dynamics.snapshot(), + decisions: sources.decisionRegistry.snapshot(), + action_journal: sources.actionJournal.snapshot(), + request_ledger: sources.requestLedger.snapshot(), + action_result_ledger: snapshotWorldActionResultStore(sources.actionResultLedger), + read_ledger: readWorldReadLedger(sources.readLedger)?.snapshot(), +}) ?? fail(); + +const assertExactWorldRuntimeCheckpoint = ( + expected: WorldCheckpoint, + sources: WorldRuntimeCheckpointSources, +): void => { + if (!isDeepStrictEqual(capture(sources), expected)) fail(); +}; + +export interface RestoreWorldRuntimeCheckpointInput { + readonly checkpoint: unknown; + readonly dynamics: DynamicsSession; + readonly capabilityManifests: readonly CapabilityManifestArtifact[]; + readonly decisionRegistry: DecisionRegistry; + readonly readLedger: WorldReadLedger; + available(): boolean; + consume(): void; +} + +export interface RestoredWorldRuntimeState extends WorldRuntimePrivateStores { + readonly checkpoint: WorldCheckpoint; + readonly mechanicsClosed: boolean; +} + +export const restoreWorldRuntimeCheckpoint = ( + input: RestoreWorldRuntimeCheckpointInput, +): RestoredWorldRuntimeState => { + const checkpoint = parseWorldCheckpoint(input.checkpoint) ?? fail(); + const manifests = [...input.capabilityManifests].sort((left, right) => + left.manifest.holder.principal < right.manifest.holder.principal ? -1 + : left.manifest.holder.principal > right.manifest.holder.principal ? 1 : 0); + const principals = sorted(manifests.map((artifact) => artifact.manifest.holder.principal)); + const dynamics = input.dynamics.snapshot(); + const decisions = input.decisionRegistry.snapshot(); + const readAuthority = readWorldReadLedger(input.readLedger) ?? fail(); + const reads = readAuthority.snapshot(); + const bindings = manifests.map(({ manifest }) => ({ + principal: manifest.holder.principal, + actor: manifest.holder.entity, + run_id: manifest.run_id, + world_id: manifest.world.id, + world_instance_id: manifest.world.instance_id, + manifest_digest: manifest.manifest_digest, + })); + const freshDynamics = dynamics.next_tick === 0 && dynamics.next_action_sequence === 1 + && dynamics.next_event_sequence === 1 && dynamics.accepted_action_sequences.floor === 1 + && dynamics.accepted_action_sequences.above_floor.length === 0 + && dynamics.action_ingress.length === 0 && dynamics.pending_actions.length === 0 + && dynamics.action_ingress_floor === 1 && dynamics.action_ingress_ordinal === 0 + && dynamics.resolved_action_sequences.floor === 1 + && dynamics.resolved_action_sequences.above_floor.length === 0; + const freshDecisions = decisions.phase === "open" && decisions.cutoffTick === null + && decisions.admissionsClosedTick === null && decisions.finalizedTick === null + && decisions.lastTick === null && decisions.nextDecisionSequence === 1 + && decisions.decisions.length === 0; + if (!freshDynamics || !freshDecisions || reads.lanes.length !== 0 + || checkpoint.static.executed_artifact_sha256 !== input.dynamics.provenance.module_sha256 + || checkpoint.static.dynamics_build_receipt_sha256 !== input.dynamics.buildReceipt.receiptSha256 + || !isDeepStrictEqual(checkpoint.static.capability_manifests, manifests) + || !isDeepStrictEqual(checkpoint.dynamics.provenance, dynamics.provenance) + || checkpoint.dynamics.seed !== dynamics.seed + || checkpoint.dynamics.sim_seconds_per_tick !== dynamics.sim_seconds_per_tick + || checkpoint.decisions.runId !== decisions.runId + || checkpoint.decisions.worldInstanceId !== decisions.worldInstanceId + || checkpoint.decisions.tokenDigestKeyFingerprint !== decisions.tokenDigestKeyFingerprint + || checkpoint.request_ledger.record_count > DYNAMICS_LIMITS.retained_action_records - 1 + || checkpoint.request_ledger.code_units > DYNAMICS_LIMITS.retained_action_code_units + || checkpoint.action_result_ledger.max_entries !== RESULT_ENTRIES + || checkpoint.action_result_ledger.max_principals !== RESULT_PRINCIPALS + || checkpoint.read_ledger.max_entries_per_principal !== reads.max_entries_per_principal + || checkpoint.read_ledger.max_principals !== reads.max_principals + || !exactPrincipals(checkpoint.action_journal.lanes.map((lane) => lane.principal), principals) + || !isDeepStrictEqual(checkpoint.action_result_ledger.bindings, bindings) + || !exactPrincipals(checkpoint.read_ledger.lanes.map((lane) => lane.principal), principals) + || checkpoint.action_journal.closed !== checkpoint.request_ledger.closed) fail(); + + if (!input.available()) fail(); + const stores = createWorldRuntimePrivateStores(); + const resultAuthority = readWorldActionResultLedger(stores.actionResultLedger) ?? fail(); + input.dynamics.restore(checkpoint.dynamics); + input.decisionRegistry.restore(checkpoint.decisions); + stores.actionJournal.restore(checkpoint.action_journal); + stores.requestLedger.restore(checkpoint.request_ledger); + resultAuthority.importState(checkpoint.action_result_ledger); + readAuthority.restore(checkpoint.read_ledger); + assertExactWorldRuntimeCheckpoint(checkpoint, { + ...stores, + dynamics: input.dynamics, + capabilityManifests: input.capabilityManifests, + decisionRegistry: input.decisionRegistry, + readLedger: input.readLedger, + }); + input.consume(); + return Object.freeze({ ...stores, checkpoint, mechanicsClosed: checkpoint.action_journal.closed }); +}; diff --git a/src/world/checkpointRuntime.test.ts b/src/world/checkpointRuntime.test.ts new file mode 100644 index 0000000..aee8eef --- /dev/null +++ b/src/world/checkpointRuntime.test.ts @@ -0,0 +1,217 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createDynamicsSession } from "../dynamics/session.js"; +import { readWorldActionResultLedger } from "./actionResultLedger.js"; +import { readWorldRuntimeActionResultLedgerInspection } from "./actionResultLedgerInspection.js"; +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import { parseWorldCheckpoint } from "./checkpoint.js"; +import { readWorldRuntimeCheckpointCoordinator } from "./checkpointRuntime.js"; +import { reserveDecisionForAct } from "./decisionRegistry.js"; +import { createWorldRuntime, type WorldRuntime } from "./runtime.js"; +import { runtimeFixture } from "./runtime.test-helper.js"; +import * as worldBarrel from "./index.js"; +import * as packageBarrel from "../index.js"; + +const artifactSha = "1".repeat(64); +const receiptSha = "2".repeat(64); + +const issuedCheckpointFixture = () => { + const base = runtimeFixture(false); + const providerState: Record = { value: 0 }; + let runtime: WorldRuntime | undefined; + let coordinator: ReturnType; + let reenterSnapshot = false; + let clockDuringSnapshot = false; + let captureDuringStep = false; + let closeOnStep = false; + let nestedRuntimeError: unknown; + let nestedClockError: unknown; + let nestedCaptureError: unknown; + const dynamics = createDynamicsSession({ + api_version: "simfile.dynamics-provider.v1", + id: "checkpoint-test", + version: "1", + state_schema_version: "v1", + integration: {}, + initialize: () => {}, + observe: () => ({ channels: [] }), + restore: (value) => { + Object.assign(providerState, value); + for (const key of Object.keys(providerState)) if (!Object.hasOwn(value as object, key)) delete providerState[key]; + }, + snapshot: () => { + if (reenterSnapshot) { + try { runtime!.status({ principal: "principal-red", decisionToken: base.red.token }); } + catch (error) { nestedRuntimeError = error; } + } + if (clockDuringSnapshot) { + try { readWorldRuntimeClockAuthority(runtime!)!.stepDynamics(); } + catch (error) { nestedClockError = error; } + } + return structuredClone(providerState) as never; + }, + step: (input) => { + if (captureDuringStep) { + try { coordinator!.capture(); } + catch (error) { nestedCaptureError = error; } + } + const source = input as { readonly tick: number }; + return { action_results: [], events: [], tick: closeOnStep ? source.tick + 1 : source.tick }; + }, + }, { + buildReceipt: { receiptSha256: receiptSha }, + config: {}, + seed: "seed", + simSecondsPerTick: 1, + provenance: { + api_version: "simfile.dynamics-provider.v1", + config_sha256: "0".repeat(64), + module: "checkpoint-test", + module_sha256: artifactSha, + node_version: "test", + numeric_model: "ieee754-binary64", + provider_dependencies: {}, + provider_id: "checkpoint-test", + provider_version: "1", + state_schema_version: "v1", + }, + } as never); + runtime = createWorldRuntime({ + dynamics, + surfaceRegistry: base.surfaceRegistry, + capabilityManifests: base.capabilityManifests, + boundGrants: base.boundGrants, + decisionRegistry: base.decisionRegistry, + readLedger: base.readLedger, + }); + coordinator = readWorldRuntimeCheckpointCoordinator(runtime); + assert.ok(coordinator); + return { + ...base, + dynamics, + runtime, + coordinator, + nestedRuntimeError: () => nestedRuntimeError, + nestedClockError: () => nestedClockError, + nestedCaptureError: () => nestedCaptureError, + reenterSnapshot: (enabled: boolean) => { reenterSnapshot = enabled; nestedRuntimeError = undefined; }, + clockDuringSnapshot: (enabled: boolean) => { clockDuringSnapshot = enabled; nestedClockError = undefined; }, + captureDuringStep: (enabled: boolean) => { captureDuringStep = enabled; nestedCaptureError = undefined; }, + closeOnStep: (enabled: boolean) => { closeOnStep = enabled; }, + }; +}; + +test("host-only coordinator captures one valid immutable whole-world checkpoint", () => { + const fixture = issuedCheckpointFixture(); + const first = fixture.coordinator.capture(); + const second = fixture.coordinator.capture(); + assert.deepEqual(second, first); + assert.ok(parseWorldCheckpoint(first)); + assert.equal(first.static.executed_artifact_sha256, artifactSha); + assert.equal(first.static.dynamics_build_receipt_sha256, receiptSha); + assert.deepEqual(first.static.capability_manifests, fixture.capabilityManifests); + assert.notEqual(first.static.capability_manifests, fixture.capabilityManifests); + assert.equal(first.dynamics.next_tick, fixture.dynamics.nextTick); + assert.equal(Object.isFrozen(first), true); + assert.equal(Object.isFrozen(first.static), true); + assert.equal(Object.isFrozen(first.action_result_ledger), true); + const bytes = Buffer.from(JSON.stringify(first)); + assert.equal(bytes.includes(Buffer.from(fixture.red.token)), false); + assert.equal(bytes.includes(Buffer.from(fixture.blue.token)), false); + assert.deepEqual(Reflect.ownKeys(fixture.coordinator), ["capture"]); + assert.equal("capture" in fixture.runtime, false); + assert.equal("readWorldRuntimeCheckpointCoordinator" in worldBarrel, false); + assert.equal("readWorldRuntimeCheckpointCoordinator" in packageBarrel, false); +}); + +test("capture reentry fails without an audit or component mutation and then retries", () => { + const fixture = issuedCheckpointFixture(); + const before = fixture.coordinator.capture(); + fixture.reenterSnapshot(true); + assert.throws(() => fixture.coordinator.capture(), /capture unavailable/u); + assert.ok(fixture.nestedRuntimeError()); + fixture.reenterSnapshot(false); + assert.deepEqual(fixture.coordinator.capture(), before); +}); + +test("a nested clock call during capture fails without mutation and then retries", () => { + const fixture = issuedCheckpointFixture(); + const before = fixture.coordinator.capture(); + fixture.clockDuringSnapshot(true); + assert.throws(() => fixture.coordinator.capture(), /capture unavailable/u); + assert.match(String(fixture.nestedClockError()), /reentry/u); + fixture.clockDuringSnapshot(false); + assert.deepEqual(fixture.coordinator.capture(), before); +}); + +test("a live owner reservation rejects capture and exact abort permits retry", () => { + const fixture = issuedCheckpointFixture(); + const before = fixture.coordinator.capture(); + const reservation = reserveDecisionForAct(fixture.decisionRegistry, { + principal: "principal-red", + runId: "run-1", + worldInstanceId: "instance-1", + token: fixture.red.token, + atTick: 0, + }); + assert.throws(() => fixture.coordinator.capture()); + reservation.abort(); + assert.deepEqual(fixture.coordinator.capture(), before); +}); + +test("a late result reservation rejects capture and exact abort permits retry", () => { + const fixture = issuedCheckpointFixture(); + const before = fixture.coordinator.capture(); + const resultLedger = readWorldRuntimeActionResultLedgerInspection(fixture.runtime); + const authority = readWorldActionResultLedger(resultLedger); + assert.ok(authority); + const reservation = authority.reserveBatch({ + actions: [{ + principal: "principal-red", + receipt_id: "world-act-1", + decision_id: fixture.red.decisionId, + action_sequence: 1, + declared_rejection_codes: [], + }], + effect_capacity: 0, + }); + assert.throws(() => fixture.coordinator.capture()); + reservation.abort(); + assert.deepEqual(fixture.coordinator.capture(), before); +}); + +test("capture attempted inside a clock tick does not poison the tick", () => { + const fixture = issuedCheckpointFixture(); + fixture.captureDuringStep(true); + assert.deepEqual(readWorldRuntimeClockAuthority(fixture.runtime)!.stepDynamics(), { + tick: 0, + action_results: 0, + events: 0, + }); + assert.match(String(fixture.nestedCaptureError()), /not stable/u); + fixture.captureDuringStep(false); + assert.equal(fixture.coordinator.capture().dynamics.next_tick, 1); +}); + +test("a closed but quiescent runtime remains capturable", () => { + const fixture = issuedCheckpointFixture(); + fixture.closeOnStep(true); + assert.throws(() => readWorldRuntimeClockAuthority(fixture.runtime)!.stepDynamics()); + fixture.closeOnStep(false); + assert.throws(() => readWorldRuntimeClockAuthority(fixture.runtime)!.stepDynamics(), /closed/u); + const closed = fixture.coordinator.capture(); + assert.equal(closed.action_journal.closed, true); + assert.equal(closed.request_ledger.closed, true); + assert.equal(closed.dynamics.next_tick, 0); + assert.ok(parseWorldCheckpoint(closed)); + assert.deepEqual(fixture.coordinator.capture(), closed); +}); + +test("invalid static receipt fails capture without changing legacy runtime behavior", () => { + const fixture = runtimeFixture(); + const coordinator = readWorldRuntimeCheckpointCoordinator(fixture.runtime); + assert.ok(coordinator); + assert.throws(() => coordinator.capture(), /capture unavailable/u); + assert.equal(fixture.runtime.status({ principal: "principal-red", decisionToken: fixture.red.token }).identity.state_version, 0); +}); diff --git a/src/world/checkpointRuntime.ts b/src/world/checkpointRuntime.ts new file mode 100644 index 0000000..fe0a331 --- /dev/null +++ b/src/world/checkpointRuntime.ts @@ -0,0 +1,84 @@ +import { readCheckedDynamicsSession, type DynamicsSession } from "../dynamics/session.js"; +import { readWorldActionJournal, type WorldActionJournal } from "./actionJournal.js"; +import { snapshotWorldActionResultStore } from "./actionResultLedgerSnapshot.js"; +import { readWorldActionResultLedger, type WorldActionResultLedger } from "./actionResultLedger.js"; +import { WORLD_CHECKPOINT_VERSION, parseWorldCheckpoint, type WorldCheckpoint } from "./checkpoint.js"; +import type { CapabilityManifestArtifact } from "./capabilityManifest.js"; +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import { readDecisionRegistry, type DecisionRegistry } from "./decisionRegistry.js"; +import { readWorldReadLedger, type WorldReadLedger } from "./ledger.js"; +import { readWorldRequestLedger, type WorldRequestLedger } from "./requestLedger.js"; + +export interface WorldRuntimeCheckpointCoordinator { + capture(): WorldCheckpoint; +} + +interface CheckpointOperation { + enter(): void; + leave(): void; + stable(): boolean; +} + +interface WorldRuntimeCheckpointRegistration { + readonly dynamics: DynamicsSession; + readonly capabilityManifests: readonly CapabilityManifestArtifact[]; + readonly decisionRegistry: DecisionRegistry; + readonly actionJournal: WorldActionJournal; + readonly requestLedger: WorldRequestLedger; + readonly actionResultLedger: WorldActionResultLedger; + readonly readLedger: WorldReadLedger; + readonly operation: CheckpointOperation; +} + +const coordinators = new WeakMap(); +const invalid = (): never => { throw new Error("world checkpoint capture unavailable"); }; + +export const registerWorldRuntimeCheckpointCoordinator = ( + runtime: object, + registration: WorldRuntimeCheckpointRegistration, +): void => { + const dynamics = readCheckedDynamicsSession(registration.dynamics) ?? invalid(); + const decisionRegistry = readDecisionRegistry(registration.decisionRegistry) ?? invalid(); + const actionJournal = readWorldActionJournal(registration.actionJournal) ?? invalid(); + const requestLedger = readWorldRequestLedger(registration.requestLedger) ?? invalid(); + const actionResultAuthority = readWorldActionResultLedger(registration.actionResultLedger) ?? invalid(); + const readLedgerAuthority = readWorldReadLedger(registration.readLedger) ?? invalid(); + if (readWorldRuntimeClockAuthority(runtime) === undefined || coordinators.has(runtime) + || !Array.isArray(registration.capabilityManifests) || registration.capabilityManifests.length === 0 + || typeof registration.operation.enter !== "function" || typeof registration.operation.leave !== "function" + || typeof registration.operation.stable !== "function") invalid(); + + const capture = (): WorldCheckpoint => { + let entered = false; + try { + registration.operation.enter(); + entered = true; + const checkpoint = parseWorldCheckpoint({ + version: WORLD_CHECKPOINT_VERSION, + static: { + executed_artifact_sha256: dynamics.provenance.module_sha256, + dynamics_build_receipt_sha256: dynamics.buildReceipt.receiptSha256, + capability_manifests: registration.capabilityManifests, + }, + dynamics: dynamics.snapshot(), + decisions: decisionRegistry.snapshot(), + action_journal: actionJournal.snapshot(), + request_ledger: requestLedger.snapshot(), + action_result_ledger: snapshotWorldActionResultStore(registration.actionResultLedger), + read_ledger: readLedgerAuthority.snapshot(), + }) ?? invalid(); + if (!registration.operation.stable()) invalid(); + return checkpoint; + } finally { + if (entered) registration.operation.leave(); + } + }; + coordinators.set(runtime, Object.freeze({ capture })); +}; + +export const readWorldRuntimeCheckpointCoordinator = ( + runtime: unknown, +): WorldRuntimeCheckpointCoordinator | undefined => + runtime !== null && typeof runtime === "object" && readWorldRuntimeClockAuthority(runtime) !== undefined + ? coordinators.get(runtime) + : undefined; diff --git a/src/world/checkpointSnapshot.ts b/src/world/checkpointSnapshot.ts new file mode 100644 index 0000000..926f268 --- /dev/null +++ b/src/world/checkpointSnapshot.ts @@ -0,0 +1,124 @@ +import { types } from "node:util"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { copyCheckpointDynamicsSnapshot } from "./checkpointDynamicsSnapshot.js"; +import { parseCapabilityManifest, serializeCapabilityManifest, type CapabilityManifest, type CapabilityManifestArtifact } from "./capabilityManifest.js"; +import { parseDecisionRegistrySnapshot, type DecisionRegistrySnapshot } from "./decisionRegistrySnapshot.js"; +import { parseWorldActionJournalSnapshot, type WorldActionJournalSnapshot } from "./actionJournalSnapshot.js"; +import { parseWorldRequestLedgerSnapshot, type WorldRequestLedgerSnapshot } from "./requestLedgerSnapshot.js"; +import { parseWorldReadLedgerSnapshot, type WorldReadLedgerSnapshot } from "./readLedgerSnapshot.js"; +import { parseWorldActionResultLedgerSnapshot } from "./actionResultLedgerSnapshot.js"; +import type { LedgerSnapshotState } from "./actionResultLedger.js"; +import type { DynamicsSessionSnapshot } from "../dynamics/types.js"; + +const SHA256 = /^[a-f0-9]{64}$/u; +const MANIFEST_SHA256 = /^sha256:[a-f0-9]{64}$/u; +const dangerous = new Set(["__proto__", "constructor", "prototype"]); +const exact = (value: unknown, keys: readonly string[]): Record | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value) || types.isProxy(value as object)) return undefined; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return undefined; + const own = Reflect.ownKeys(value); + if (own.length !== keys.length || own.some((key) => typeof key !== "string" || dangerous.has(key) || !keys.includes(key))) return undefined; + const output: Record = Object.create(null); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) return undefined; + output[key] = descriptor.value; + } + return output; +}; +const values = (value: unknown, maximum: number): readonly unknown[] | undefined => { + if (!Array.isArray(value) || types.isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const length = Object.getOwnPropertyDescriptor(value, "length"); + if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || length.value < 0 || length.value > maximum) return undefined; + const own = Reflect.ownKeys(value); + if (own.length !== length.value + 1 || own.some((key) => typeof key !== "string" || (key !== "length" && (!/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= length.value)))) return undefined; + const output: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !("value" in descriptor)) return undefined; + output.push(descriptor.value); + } + return output; +}; +const scan = (value: unknown, seen: Set, depth = 0): boolean => { + if (depth > 24) return false; + if (value === null || typeof value !== "object") return true; + if (types.isProxy(value) || seen.has(value as object)) return false; + seen.add(value as object); + const array = Array.isArray(value); + const prototype = Object.getPrototypeOf(value); + if ((array && prototype !== Array.prototype) || (!array && prototype !== Object.prototype && prototype !== null)) return false; + if (!array && Object.getOwnPropertyDescriptor(value, "then") !== undefined) return false; + for (const key of Reflect.ownKeys(value)) { + if (key === "length") continue; + if (typeof key !== "string" || dangerous.has(key)) return false; + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) return false; + if (key !== "length" && !scan(descriptor.value, seen, depth + 1)) return false; + } + return true; +}; +const bytes = (value: unknown): readonly number[] | undefined => { + const raw = values(value, DYNAMICS_LIMITS.retained_action_code_units); + if (raw === undefined || raw.length === 0) return undefined; + const output = raw.map((entry) => typeof entry === "number" && Number.isInteger(entry) && entry >= 0 && entry <= 255 ? entry : -1); + return output.some((entry) => entry < 0) ? undefined : Object.freeze(output); +}; +const parseManifests = (input: unknown): readonly CapabilityManifestArtifact[] | undefined => { + const raw = values(input, 4096); + if (raw === undefined) return undefined; + const output: CapabilityManifestArtifact[] = []; + const principals = new Set(); + for (const entry of raw) { + const source = exact(entry, ["manifest", "bytes", "digest"]); + const data = source === undefined ? undefined : bytes(source.bytes); + if (source === undefined || data === undefined || typeof source.digest !== "string" || !MANIFEST_SHA256.test(source.digest)) return undefined; + let manifest; + try { manifest = parseCapabilityManifest(data); } catch { return undefined; } + const canonical = serializeCapabilityManifest(manifest); + const declared = serializeCapabilityManifest(source.manifest as CapabilityManifest); + if (canonical.length !== data.length || canonical.some((byte, index) => byte !== data[index]) + || declared.length !== data.length || declared.some((byte, index) => byte !== data[index]) + || source.digest !== manifest.manifest_digest || principals.has(manifest.holder.principal)) return undefined; + principals.add(manifest.holder.principal); + output.push(Object.freeze({ manifest, bytes: data, digest: source.digest })); + } + output.sort((left, right) => left.manifest.holder.principal < right.manifest.holder.principal ? -1 : left.manifest.holder.principal > right.manifest.holder.principal ? 1 : 0); + return Object.freeze(output); +}; +const parseDecisions = (input: unknown): DecisionRegistrySnapshot | undefined => { + const source = exact(input, ["version", "runId", "worldInstanceId", "tokenDigestKeyFingerprint", "phase", "cutoffTick", "admissionsClosedTick", "finalizedTick", "lastTick", "nextDecisionSequence", "decisions"]); + if (source === undefined || typeof source.runId !== "string" || typeof source.worldInstanceId !== "string" || typeof source.tokenDigestKeyFingerprint !== "string") return undefined; + const parsed = parseDecisionRegistrySnapshot(input, { runId: source.runId, worldInstanceId: source.worldInstanceId, tokenDigestKeyFingerprint: source.tokenDigestKeyFingerprint }); + if (parsed === undefined) return undefined; + return Object.freeze({ ...source, ...parsed, version: source.version }) as DecisionRegistrySnapshot; +}; + +export interface WorldCheckpointStatic { + readonly executed_artifact_sha256: string; + /** Opaque issuer identity; C3 compares it with the live authoritative receipt. */ + readonly dynamics_build_receipt_sha256: string; + readonly capability_manifests: readonly CapabilityManifestArtifact[]; +} +export interface WorldCheckpointSnapshot { readonly static: WorldCheckpointStatic; readonly dynamics: DynamicsSessionSnapshot; readonly decisions: DecisionRegistrySnapshot; readonly action_journal: WorldActionJournalSnapshot; readonly request_ledger: WorldRequestLedgerSnapshot; readonly action_result_ledger: LedgerSnapshotState; readonly read_ledger: WorldReadLedgerSnapshot; } +export const copyWorldCheckpointSnapshot = (input: unknown): WorldCheckpointSnapshot | undefined => { + try { + const root = exact(input, ["version", "static", "dynamics", "decisions", "action_journal", "request_ledger", "action_result_ledger", "read_ledger"]); + const staticValue = root === undefined ? undefined : exact(root.static, ["executed_artifact_sha256", "dynamics_build_receipt_sha256", "capability_manifests"]); + if (root === undefined || root.version !== "simfile.world-checkpoint.v1" || staticValue === undefined + || typeof staticValue.executed_artifact_sha256 !== "string" || !SHA256.test(staticValue.executed_artifact_sha256) + || typeof staticValue.dynamics_build_receipt_sha256 !== "string" || !SHA256.test(staticValue.dynamics_build_receipt_sha256)) return undefined; + const manifests = parseManifests(staticValue.capability_manifests); + const dynamics = copyCheckpointDynamicsSnapshot(root.dynamics); + const decisions = parseDecisions(root.decisions); + const journal = parseWorldActionJournalSnapshot(root.action_journal); + const requests = parseWorldRequestLedgerSnapshot(root.request_ledger); + const results = parseWorldActionResultLedgerSnapshot(root.action_result_ledger); + const reads = parseWorldReadLedgerSnapshot(root.read_ledger); + if (manifests === undefined || dynamics === undefined || decisions === undefined || journal === undefined || requests === undefined || results === undefined || reads === undefined) return undefined; + // Owner parsers establish caps and section shape before this whole-input alias graph check. + if (!scan(input, new Set())) return undefined; + return Object.freeze({ static: Object.freeze({ executed_artifact_sha256: staticValue.executed_artifact_sha256, dynamics_build_receipt_sha256: staticValue.dynamics_build_receipt_sha256, capability_manifests: manifests }), dynamics, decisions, action_journal: journal, request_ledger: requests, action_result_ledger: results, read_ledger: reads }); + } catch { return undefined; } +}; diff --git a/src/world/clockAuthority.test.ts b/src/world/clockAuthority.test.ts new file mode 100644 index 0000000..f11fc2b --- /dev/null +++ b/src/world/clockAuthority.test.ts @@ -0,0 +1,329 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { createWorldActionResultLedger, readWorldActionResultLedger } from "./actionResultLedger.js"; +import { registerWorldRuntimeActionResultLedgerInspection, readWorldRuntimeActionResultLedgerInspection } from "./actionResultLedgerInspection.js"; +import { actWorldRuntime, denyWith } from "./act.js"; +import { createWorldActionJournal } from "./actionJournal.js"; +import { createWorldRuntimeClockAuthority, readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import { createWorldRequestLedger } from "./requestLedger.js"; +import { runtimeActionResultLedger, runtimeActEnvelope, runtimeActionJournalSnapshot, runtimeActionJournalStatus, runtimeFixture, runtimeFixtureWithHooks } from "./runtime.test-helper.js"; +import * as worldBarrel from "./index.js"; +import * as packageBarrel from "../index.js"; +import type { WorldActIngressRejectionReason } from "./actTypes.js"; + +const denied = (reason: WorldActIngressRejectionReason) => Object.freeze({ + disposition: "rejected_at_ingress" as const, + code: "world_action_denied" as const, + reason, +}); + +test("does not expose clock authority on WorldRuntime", () => { + const fixture = runtimeFixture(); assert.equal("stepDynamics" in fixture.runtime, false); assert.ok(readWorldRuntimeClockAuthority(fixture.runtime)); +}); + +test("provider reentry is denied before a second queue and makes projection fail", () => { + let nested: unknown; let projectCalls = 0; let act: (() => unknown) | undefined; + const fixture = runtimeFixtureWithHooks({ + step: (input) => { + nested = act!(); + const action = (input as { actions: readonly { sequence: number }[] }).actions[0]!; + return { tick: 0, events: [], action_results: [{ accepted: true, sequence: action.sequence }] }; + }, + projectResult: () => { projectCalls += 1; return { effect: "unexpected" }; }, + }); + act = () => fixture.runtime!.act({ principal: "principal-blue", decisionToken: fixture.blue.token }, runtimeActEnvelope("clock-blue-reentry", { affordance: "world://pitch/affordance/wait", target: "world://pitch/entity/blue", input: { force: 1 } })); + assert.equal(fixture.runtime!.act({ principal: "principal-red", decisionToken: fixture.red.token }, runtimeActEnvelope("clock-red", { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } })).disposition, "queued"); + readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + assert.deepEqual(nested, denied("ingress_reentered")); + assert.equal(projectCalls, 0); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + assert.equal(fixture.runtime!.act({ principal: "principal-blue", decisionToken: fixture.blue.token }, runtimeActEnvelope("clock-blue-after", { affordance: "world://pitch/affordance/wait", target: "world://pitch/entity/blue", input: { force: 1 } })).disposition, "queued"); +}); + +test("caught projection and nested clock reentry cannot run a second clock", () => { + let nestedClockCalls = 0; let clock: ReturnType; + const fixture = runtimeFixtureWithHooks({ + step: (input) => { + try { clock!.stepDynamics(); } catch { nestedClockCalls += 1; } + const action = (input as { actions: readonly { sequence: number }[] }).actions[0]!; + return { tick: 0, events: [], action_results: [{ accepted: true, sequence: action.sequence }] }; + }, + }); + clock = readWorldRuntimeClockAuthority(fixture.runtime!); + assert.equal(fixture.runtime!.act({ principal: "principal-red", decisionToken: fixture.red.token }, runtimeActEnvelope("clock-red-projection", { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } })).disposition, "queued"); + clock!.stepDynamics(); + assert.equal(nestedClockCalls, 1); + assert.equal(fixture.dynamics.nextTick, 1); +}); + +test("caught projection reentry receives one denial without an orphaned queue", () => { + let projectCalls = 0; let nested: unknown; let act: (() => unknown) | undefined; + const fixture = runtimeFixtureWithHooks({ + step: (input) => { + const action = (input as { actions: readonly { sequence: number }[] }).actions[0]!; + return { tick: 0, events: [], action_results: [{ accepted: true, sequence: action.sequence }] }; + }, + projectResult: () => { projectCalls += 1; nested = act!(); return { ignored: true }; }, + }); + act = () => fixture.runtime!.act({ principal: "principal-blue", decisionToken: fixture.blue.token }, runtimeActEnvelope("clock-blue-projection-reentry", { affordance: "world://pitch/affordance/wait", target: "world://pitch/entity/blue", input: { force: 1 } })); + assert.equal(fixture.runtime!.act({ principal: "principal-red", decisionToken: fixture.red.token }, runtimeActEnvelope("clock-red-projection", { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } })).disposition, "queued"); + readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + assert.equal(projectCalls, 1); + assert.deepEqual(nested, denied("ingress_reentered")); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + assert.equal(fixture.runtime!.act({ principal: "principal-blue", decisionToken: fixture.blue.token }, runtimeActEnvelope("clock-blue-after-projection", { affordance: "world://pitch/affordance/wait", target: "world://pitch/entity/blue", input: { force: 1 } })).disposition, "queued"); +}); + +test("provider throws abort the terminal reservation and retain pending mechanics", () => { + let throws = true; + const fixture = runtimeFixtureWithHooks({ step: (input) => { + if (throws) { throws = false; throw new Error("provider"); } + const action = (input as { actions: readonly { sequence: number }[] }).actions[0]!; + return { tick: 0, events: [], action_results: [{ accepted: true, sequence: action.sequence }] }; + } }); + assert.equal(fixture.runtime!.act({ principal: "principal-red", decisionToken: fixture.red.token }, runtimeActEnvelope("clock-red-throw", { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } })).disposition, "queued"); + const clock = readWorldRuntimeClockAuthority(fixture.runtime!)!; + assert.throws(() => clock.stepDynamics()); + assert.equal(fixture.dynamics.nextTick, 0); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 1); + assert.equal(clock.stepDynamics().tick, 0); +}); + +type IssuedFixture = ReturnType & { + readonly runtime: NonNullable["runtime"]>; +}; +const bindResultLedger = (fixture: IssuedFixture) => { + const ledger = runtimeActionResultLedger(fixture.runtime); + assert.ok(ledger); + return ledger; +}; + +const pageRequest = Object.freeze({ version: "simfile.world-action-result-page-request.v1" as const, limit: 100 }); +const queue = (fixture: IssuedFixture, principal: "principal-red" | "principal-blue", token: string, requestId: string, affordance: "kick" | "wait") => { + const target = principal === "principal-red" ? "world://pitch/entity/ball" : "world://pitch/entity/blue"; + const receipt = fixture.runtime.act({ principal, decisionToken: token }, runtimeActEnvelope(requestId, { + affordance: `world://pitch/affordance/${affordance}`, target, input: { force: 1 }, + })); + assert.equal(receipt.disposition, "queued"); return receipt; +}; + +const lowerClockFixture = (hooks: Parameters[0], wrongBinding = false, register = true) => { + const fixture = runtimeFixtureWithHooks(hooks, false); + const runtime = {}; + const journal = createWorldActionJournal(); + journal.reservePrincipals(fixture.capabilityManifests.map(({ manifest }) => manifest.holder.principal)); + const requestLedger = createWorldRequestLedger({ max_records: DYNAMICS_LIMITS.retained_action_records - 1 }); + let closed = false; + const operation = Object.freeze({ + enter: () => { if (closed) throw new Error("lower clock closed"); }, leave: () => {}, reentered: () => false, + close: () => { closed = true; requestLedger.close(); journal.close(); }, + }); + createWorldRuntimeClockAuthority(runtime, { dynamics: fixture.dynamics, surfaceRegistry: fixture.surfaceRegistry, journal, operation }); + const ledger = createWorldActionResultLedger(); + readWorldActionResultLedger(ledger)!.reserve({ bindings: fixture.capabilityManifests.map(({ manifest }) => ({ + principal: manifest.holder.principal, actor: wrongBinding ? "world://pitch/entity/blue" : manifest.holder.entity, run_id: manifest.run_id, + world_id: manifest.world.id, world_instance_id: manifest.world.instance_id, manifest_digest: manifest.manifest_digest, + })) }); + const bind = () => registerWorldRuntimeActionResultLedgerInspection(runtime, ledger); + if (register) bind(); + const queueLower = (principal: "principal-red" | "principal-blue", token: string, requestId: string, affordance: "kick" | "wait") => { + const manifest = fixture.capabilityManifests.find(({ manifest: value }) => value.holder.principal === principal)!.manifest; + return actWorldRuntime({ dynamics: fixture.dynamics, surfaceRegistry: fixture.surfaceRegistry, decisionRegistry: fixture.decisionRegistry, + journal, requestLedger, runId: "run-1", worldId: "pitch", worldInstanceId: "instance-1", closeMechanics: operation.close, refuse: denyWith }, + manifest, principal, token, runtimeActEnvelope(requestId, { affordance: `world://pitch/affordance/${affordance}`, + target: principal === "principal-red" ? "world://pitch/entity/ball" : "world://pitch/entity/blue", input: { force: 1 } }), () => false); + }; + return { ...fixture, runtime, journal, ledger, bind, queueLower, closed: () => closed }; +}; + +test("result inspection is issued, read-only, isolated, and reserves before the real checked step", () => { + let sawStep = false; + const fixture = runtimeFixtureWithHooks({ + step: (input) => { + sawStep = true; + const action = (input as { readonly actions: readonly { readonly sequence: number }[] }).actions[0]!; + return { tick: 0, events: [], action_results: [{ accepted: true, sequence: action.sequence }] }; + }, + }); + bindResultLedger(fixture as IssuedFixture); + const handle = readWorldRuntimeActionResultLedgerInspection(fixture.runtime!)!; + assert.deepEqual(Reflect.ownKeys(handle), ["read"]); + assert.equal(Object.isFrozen(handle), true); + assert.equal("reserveBatch" in handle, false); + assert.ok(readWorldRuntimeActionResultLedgerInspection(runtimeFixtureWithHooks({}).runtime)); + const receipt = fixture.runtime!.act({ principal: "principal-red", decisionToken: fixture.red.token }, runtimeActEnvelope("result-clock", { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } })); + assert.equal(receipt.disposition, "queued"); + assert.equal(handle.read("principal-red", { version: "simfile.world-action-result-page-request.v1" }).results.length, 0); + assert.deepEqual(readWorldRuntimeClockAuthority(fixture.runtime)!.stepDynamics(), { tick: 0, action_results: 1, events: 0 }); + assert.equal(sawStep, true); + const page = handle.read("principal-red", { version: "simfile.world-action-result-page-request.v1" }); + assert.equal(page.results.length, 1); + assert.equal(page.results[0]!.identity.state_version, 1); + assert.equal(page.results[0]!.identity.manifest_digest, fixture.capabilityManifests.find(({ manifest }) => manifest.holder.principal === "principal-red")!.manifest.manifest_digest); +}); + +test("result inspection rejects forged, reused, cross-registered, and trapped inputs without executing traps", () => { + const first = runtimeFixtureWithHooks({}); const second = runtimeFixtureWithHooks({}); const ledger = createWorldActionResultLedger(); + const fake = { read: () => { throw new Error("fake"); } } as never; + assert.throws(() => registerWorldRuntimeActionResultLedgerInspection({} as object, ledger), /invalid/u); + assert.throws(() => registerWorldRuntimeActionResultLedgerInspection(first.runtime!, fake), /invalid/u); + bindResultLedger(first as IssuedFixture); + const firstLedger = readWorldRuntimeActionResultLedgerInspection(first.runtime!)!; + assert.throws(() => registerWorldRuntimeActionResultLedgerInspection(first.runtime!, createWorldActionResultLedger()), /invalid/u); + assert.throws(() => registerWorldRuntimeActionResultLedgerInspection(second.runtime!, firstLedger), /invalid/u); + let trapped = 0; + const proxy = new Proxy(ledger, { get: () => { trapped += 1; return undefined; }, ownKeys: () => { trapped += 1; return []; } }); + assert.throws(() => registerWorldRuntimeActionResultLedgerInspection(second.runtime!, proxy), /invalid/u); + const runtimeProxy = new Proxy(first.runtime!, { get: () => { trapped += 1; return undefined; }, ownKeys: () => { trapped += 1; return []; } }); + assert.throws(() => registerWorldRuntimeActionResultLedgerInspection(runtimeProxy, createWorldActionResultLedger()), /invalid/u); + assert.throws(() => registerWorldRuntimeActionResultLedgerInspection(second.runtime!, { ...ledger }), /invalid/u); + const accessor = Object.create(Object.prototype, { read: { enumerable: true, get: () => { trapped += 1; return ledger.read; } } }); + assert.throws(() => registerWorldRuntimeActionResultLedgerInspection(second.runtime!, accessor), /invalid/u); + assert.equal(trapped, 0); + assert.ok(readWorldActionResultLedger(ledger)); + assert.equal("registerWorldRuntimeActionResultLedgerInspection" in worldBarrel, false); + assert.equal("registerWorldRuntimeActionResultLedgerInspection" in packageBarrel, false); +}); + +test("result mode reserves strict queued order, uses the checked event ceiling, and publishes zero-action ticks without a batch", () => { + let eventCount = 0; let authority: ReturnType; + const fixture = runtimeFixtureWithHooks({ step: (input) => { + assert.equal(authority!.hasLiveReservation(), true); + const actions = (input as { readonly actions: readonly { readonly sequence: number }[] }).actions; + eventCount = DYNAMICS_LIMITS.events_per_tick; + return { tick: (input as { readonly tick: number }).tick, action_results: actions.map(({ sequence }) => ({ accepted: true, sequence })), events: Array.from({ length: eventCount }, () => ({ + cause_action_sequences: [1, 2], kind: "impact", payload: { strength: 1 }, source: "system:test", target: "object:ball", + })) }; + } }); + const ledger = bindResultLedger(fixture as IssuedFixture); + authority = readWorldActionResultLedger(ledger)!; + assert.deepEqual(readWorldRuntimeActionResultLedgerInspection(fixture.runtime!)!.read("principal-red", pageRequest).results, []); + queue(fixture as IssuedFixture, "principal-red", fixture.red.token, "ceiling-red", "kick"); + queue(fixture as IssuedFixture, "principal-blue", fixture.blue.token, "ceiling-blue", "wait"); + const result = readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + assert.equal(result.events, eventCount); assert.equal(authority.hasLiveReservation(), false); + const red = ledger.read("principal-red", pageRequest).results[0]!; + const blue = ledger.read("principal-blue", pageRequest).results[0]!; + assert.equal(red.result_id, "world-result-1"); assert.equal(blue.result_id, "world-result-2"); + assert.equal(red.action_sequence, 1); assert.equal(blue.action_sequence, 2); + assert.equal(red.identity.state_version, 1); assert.equal(blue.identity.state_version, 1); + assert.equal(red.identity.manifest_digest === blue.identity.manifest_digest, false); + assert.equal(red.status, "applied"); assert.equal(blue.status, "applied"); + const appliedRed = red as Extract; + const appliedBlue = blue as Extract; + assert.equal(appliedRed.caused_effect_ids.length, DYNAMICS_LIMITS.events_per_tick); + assert.deepEqual(appliedRed.caused_effect_ids.slice(0, 2), ["world-effect-1", "world-effect-2"]); + assert.equal(appliedRed.caused_effect_ids.at(-1), `world-effect-${DYNAMICS_LIMITS.events_per_tick}`); + assert.deepEqual(appliedBlue.caused_effect_ids, appliedRed.caused_effect_ids); + + let calls = 0; + let emptyAuthority: ReturnType; + const empty = runtimeFixtureWithHooks({ step: () => { calls += 1; assert.equal(emptyAuthority!.hasLiveReservation(), false); return { tick: 0, action_results: [], events: [] }; } }); + const emptyLedger = bindResultLedger(empty as IssuedFixture); + emptyAuthority = readWorldActionResultLedger(emptyLedger)!; + assert.deepEqual(readWorldRuntimeClockAuthority(empty.runtime!)!.stepDynamics(), { tick: 0, action_results: 0, events: 0 }); + assert.equal(calls, 1); assert.equal(readWorldActionResultLedger(emptyLedger)!.hasLiveReservation(), false); + assert.deepEqual(emptyLedger.read("principal-red", pageRequest).results, []); +}); + +test("result mode preserves cursor continuity and distinguishes rejected declared codes", () => { + const fixture = runtimeFixtureWithHooks({ step: (input) => ({ tick: (input as { readonly tick: number }).tick, events: [], action_results: (input as { readonly actions: readonly { readonly sequence: number }[] }).actions.map(({ sequence }) => ({ + accepted: sequence === 1, ...(sequence === 2 ? { code: "blocked" } : {}), sequence, + })) }) }); + const ledger = bindResultLedger(fixture as IssuedFixture); + const first = queue(fixture as IssuedFixture, "principal-red", fixture.red.token, "cursor-red", "kick"); + assert.equal(first.disposition, "queued"); + assert.equal(readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics().action_results, 1); + const firstPage = ledger.read("principal-red", { ...pageRequest, limit: 1 }); + assert.equal(firstPage.results[0]!.status, "applied"); assert.ok(firstPage.next_result_after); + const secondToken = fixture.decisionRegistry.mint({ principal: "principal-red", issuedTick: 1, validThroughTick: 2 }).token; + queue(fixture as IssuedFixture, "principal-red", secondToken, "cursor-red-2", "kick"); + assert.equal(readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics().action_results, 1); + const secondPage = ledger.read("principal-red", { ...pageRequest, limit: 1, result_after: firstPage.next_result_after }); + assert.equal(secondPage.results.length, 1); assert.equal(secondPage.results[0]!.result_id, "world-result-2"); + assert.equal(secondPage.results[0]!.status, "rejected_at_mechanics"); assert.equal(secondPage.results[0]!.rejection_code, "blocked"); + assert.equal("caused_effect_ids" in secondPage.results[0]!, false); assert.equal(secondPage.results[0]!.receipt_id, "world-act-2"); + assert.equal(runtimeActionJournalSnapshot(fixture.runtime!)!.cells.every((cell) => cell.terminal !== null), true); +}); + +test("an exhausted result counter fails before mechanics, settles the terminal reservation, and forbids retry", () => { + let mechanicsSteps = 0; + const fixture = lowerClockFixture({ step: (input) => { mechanicsSteps += 1; return { tick: (input as { readonly tick: number }).tick, events: [], action_results: (input as { readonly actions: readonly { readonly sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: true, sequence })) }; } }, false, false); + const first = fixture.queueLower("principal-red", fixture.red.token, "boundary-1", "kick"); + assert.equal(first.disposition, "queued"); + const clock = readWorldRuntimeClockAuthority(fixture.runtime)!; + assert.equal(clock.stepDynamics().tick, 0); + const authority = readWorldActionResultLedger(fixture.ledger)!; + const redManifest = fixture.capabilityManifests.find(({ manifest }) => manifest.holder.principal === "principal-red")!.manifest; + authority.append({ principal: "principal-red", declared_rejection_codes: [], result: { + version: "simfile.world-action-result.v1", result_id: `world-result-${Number.MAX_SAFE_INTEGER - 2}`, + receipt_id: first.receipt_id, decision_id: first.decision_id, actor: "world://pitch/entity/red", action_sequence: 1, + apply_tick: 0, status: "applied", caused_effect_ids: [], identity: { + run_id: "run-1", world_id: "pitch", world_instance_id: "instance-1", manifest_digest: redManifest.manifest_digest, state_version: 1, + }, + } }); + fixture.bind(); + const baseline = fixture.ledger.read("principal-red", pageRequest); + assert.equal(fixture.queueLower("principal-blue", fixture.blue.token, "boundary-2", "wait").disposition, "queued"); + assert.throws(() => clock.stepDynamics(), /invalid|construction/u); + assert.equal(fixture.dynamics.nextTick, 1); assert.equal(mechanicsSteps, 1); + assert.equal(authority.hasLiveReservation(), false); + assert.deepEqual(fixture.ledger.read("principal-red", pageRequest), baseline); + assert.equal(fixture.journal.snapshot().closed, true); + assert.equal(fixture.journal.snapshot().cells[0]!.state, "terminal"); + assert.ok(fixture.journal.snapshot().cells[0]!.terminal); + assert.equal(fixture.journal.snapshot().cells[1]!.terminal, null); + assert.equal(fixture.closed(), true); + assert.throws(() => clock.stepDynamics(), /closed|operation/u); +}); + +test("a valid checked step followed by publication failure keeps a committed terminal, no page, and no second step", () => { + const fixture = lowerClockFixture({ step: (input) => ({ tick: 0, events: [], action_results: (input as { readonly actions: readonly { readonly sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: true, sequence })) }) }, true); + assert.equal(fixture.queueLower("principal-red", fixture.red.token, "publish-fail", "kick").disposition, "queued"); + const clock = readWorldRuntimeClockAuthority(fixture.runtime!)!; + assert.throws(() => clock.stepDynamics()); + assert.equal(readWorldActionResultLedger(fixture.ledger)!.hasLiveReservation(), false); + assert.equal(fixture.ledger.read("principal-red", pageRequest).results.length, 0); + assert.equal(fixture.journal.snapshot().cells[0]!.terminal?.disposition, "applied"); + assert.equal(fixture.journal.snapshot().closed, true); + assert.equal(fixture.closed(), true); + assert.throws(() => clock.stepDynamics(), /closed|operation/u); +}); + +test("a failure after publication retains exactly one visible result and closes the checked session", () => { + const fixture = runtimeFixtureWithHooks({ + step: (input) => ({ tick: (input as { readonly tick: number }).tick, events: [], action_results: (input as { readonly actions: readonly { readonly sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: true, sequence })) }), + projectResult: () => { throw new Error("post-publication projection"); }, + failRestore: () => true, + }); + const ledger = bindResultLedger(fixture as IssuedFixture); + queue(fixture as IssuedFixture, "principal-red", fixture.red.token, "post-publish", "kick"); + const clock = readWorldRuntimeClockAuthority(fixture.runtime!)!; + assert.throws(() => clock.stepDynamics(), /post-publication projection|projection/u); + assert.equal(ledger.read("principal-red", pageRequest).results.length, 1); + assert.equal(ledger.read("principal-red", pageRequest).results[0]!.result_id, "world-result-1"); + assert.equal(readWorldActionResultLedger(ledger)!.hasLiveReservation(), false); + assert.equal(runtimeActionJournalStatus(fixture.runtime!)!.closed, true); + assert.throws(() => clock.stepDynamics(), /closed|operation/u); +}); + +test("result-mode step throws abort both reservations, remain retryable, and reuse the first ids", () => { + let fail = true; + const fixture = runtimeFixtureWithHooks({ step: (input) => { + if (fail) { fail = false; throw new Error("checked step"); } + const action = (input as { readonly actions: readonly { readonly sequence: number }[] }).actions[0]!; + return { tick: 0, events: [{ cause_action_sequences: [action.sequence], kind: "impact", payload: { strength: 1 }, source: "system:test", target: "object:ball" }], action_results: [{ accepted: true, sequence: action.sequence }] }; + } }); + const ledger = bindResultLedger(fixture as IssuedFixture); + const authority = readWorldActionResultLedger(ledger)!; + assert.equal(fixture.runtime!.act({ principal: "principal-red", decisionToken: fixture.red.token }, runtimeActEnvelope("result-retry", { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } })).disposition, "queued"); + const clock = readWorldRuntimeClockAuthority(fixture.runtime!)!; + assert.throws(() => clock.stepDynamics(), /checked step/u); + assert.equal(authority.hasLiveReservation(), false); + assert.equal(clock.stepDynamics().tick, 0); + const result = authority.read("principal-red", { version: "simfile.world-action-result-page-request.v1" }).results[0]!; + assert.equal(result.result_id, "world-result-1"); + assert.equal(result.action_sequence, 1); + assert.deepEqual((result as Extract).caused_effect_ids, ["world-effect-1"]); +}); diff --git a/src/world/clockAuthority.ts b/src/world/clockAuthority.ts new file mode 100644 index 0000000..f1872cf --- /dev/null +++ b/src/world/clockAuthority.ts @@ -0,0 +1,77 @@ +import { isDynamicsRetryableStepFailure } from "../dynamics/session.js"; +import type { DynamicsSession } from "../dynamics/session.js"; +import type { DynamicsStepResult } from "../dynamics/types.js"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import type { WorldSurfaceRegistry } from "../world-surface/index.js"; +import type { WorldActionJournal } from "./actionJournal.js"; +import { readWorldActionResultLedger } from "./actionResultLedger.js"; +import { readWorldRuntimeActionResultLedgerInspection } from "./actionResultLedgerInspection.js"; +import { resolveWorldActionStep } from "./actionResults.js"; +import type { WorldRuntimeControllerAuthority } from "./controllerAuthority.js"; + +export interface WorldDynamicsTickRecord { + readonly tick: number; + readonly action_results: number; + readonly events: number; + /** Host-only unfiltered mechanics result retained for run recording. */ + readonly raw_step: DynamicsStepResult; +} +export interface WorldRuntimeClockAuthority { stepDynamics(): WorldDynamicsTickRecord; } +const issued = new WeakSet(); +const clocks = new WeakMap(); +export const readWorldRuntimeClockAuthority = (runtime: unknown): WorldRuntimeClockAuthority | undefined => + runtime !== null && typeof runtime === "object" ? clocks.get(runtime) : undefined; +export const createWorldRuntimeClockAuthority = (runtime: object, dependencies: Readonly<{ + dynamics: DynamicsSession; surfaceRegistry: WorldSurfaceRegistry; journal: WorldActionJournal; + operation: { enter(): void; leave(): void; reentered(): boolean; close(): void }; + controller?: WorldRuntimeControllerAuthority; +}>): void => { + if (clocks.has(runtime)) throw new Error("world clock already issued"); + const clock: WorldRuntimeClockAuthority = Object.freeze({ stepDynamics: () => { + dependencies.operation.enter(); + let reservation: ReturnType | undefined; + let resultReservation: Parameters[0]["resultReservation"]; + let resultLedger: ReturnType; + let resultMode = false; + let stepStarted = false; + let stepCompleted = false; + let terminalsSettled = false; + try { + const publicLedger = readWorldRuntimeActionResultLedgerInspection(runtime); + resultMode = publicLedger !== undefined; + resultLedger = publicLedger === undefined ? undefined : readWorldActionResultLedger(publicLedger); + if (resultMode && resultLedger === undefined) throw new Error("world action result ledger inspection is unavailable"); + reservation = dependencies.journal.reserveTerminals(dependencies.dynamics.nextTick); + if (resultLedger !== undefined && reservation.queued.length > 0) { + const actions = reservation.queued.map((action) => { + const local = action.affordance.split("/"); + const affordance = local.length >= 2 && local.at(-2) === "affordance" + ? dependencies.surfaceRegistry.affordances.find((entry) => entry.address === `affordance:${local.at(-1)!}`) + : undefined; + if (affordance === undefined) throw new Error("invalid queued affordance"); + return { principal: action.principal, receipt_id: action.receipt_id, decision_id: action.decision_id, + action_sequence: action.dynamics_sequence, declared_rejection_codes: affordance.rejection_codes }; + }); + resultReservation = resultLedger.reserveBatch({ actions, effect_capacity: DYNAMICS_LIMITS.events_per_tick }); + } + let rawStep: DynamicsStepResult; + try { stepStarted = true; rawStep = dependencies.dynamics.step(); stepCompleted = true; } + catch (error) { reservation.abort(); terminalsSettled = true; throw error; } + const step = dependencies.controller?.settle(rawStep) ?? rawStep; + resolveWorldActionStep({ dynamics: dependencies.dynamics, surfaceRegistry: dependencies.surfaceRegistry, journal: dependencies.journal, + reservation, step, reentered: dependencies.operation.reentered, closeMechanics: dependencies.operation.close, + ...(resultReservation === undefined ? {} : { resultReservation, postMechanicsStateVersion: dependencies.dynamics.nextTick }) }); + terminalsSettled = true; + const record = { tick: rawStep.tick, action_results: rawStep.action_results.length, events: rawStep.events.length } as WorldDynamicsTickRecord; + Object.defineProperty(record, "raw_step", { enumerable: false, value: rawStep }); + return Object.freeze(record); + } catch (error) { + if (resultLedger?.hasLiveReservation() === true) resultReservation?.abort(); + if (!stepStarted && reservation !== undefined && !terminalsSettled) { reservation.abort(); terminalsSettled = true; } + if (resultMode && (!stepStarted || stepCompleted || !isDynamicsRetryableStepFailure(error))) dependencies.operation.close(); + throw error; + } finally { dependencies.operation.leave(); } + }}); + issued.add(clock); clocks.set(runtime, clock); +}; +export const isWorldRuntimeClockAuthority = (value: unknown): value is WorldRuntimeClockAuthority => value !== null && typeof value === "object" && issued.has(value); diff --git a/src/world/controllerAuthority.test.ts b/src/world/controllerAuthority.test.ts new file mode 100644 index 0000000..ff5b4f6 --- /dev/null +++ b/src/world/controllerAuthority.test.ts @@ -0,0 +1,189 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import { + readWorldRuntimeControllerAuthority, +} from "./controllerAuthority.js"; +import { + runtimeActEnvelope, + runtimeActionResultLedger, + runtimeFixtureWithHooks, +} from "./runtime.test-helper.js"; +import * as worldBarrel from "./index.js"; +import * as packageBarrel from "../index.js"; + +test("queues controller motor work beside agent intent without crossing ledgers", () => { + const fixture = runtimeFixtureWithHooks({ + step: (input) => { + const value = input as { + readonly actions: readonly { readonly sequence: number }[]; + readonly tick: number; + }; + return { + tick: value.tick, + action_results: value.actions.map(({ sequence }) => ({ + accepted: true, + sequence, + })), + events: [ + { + cause_action_sequences: [value.actions[0]!.sequence], + kind: "body.motion", + payload: { distance: 1 }, + source: "object:red", + target: "object:ball", + }, + { + cause_action_sequences: value.actions.map(({ sequence }) => sequence), + kind: "impact", + payload: { strength: 1 }, + source: "object:red", + target: "object:ball", + }, + ], + }; + }, + }); + const controller = readWorldRuntimeControllerAuthority(fixture.runtime!); + assert.ok(controller); + const motor = controller.queue({ + action: "move", + actor: "object:red", + controller_id: "red-body", + controller_version: "test-v1", + input: { x: 1 }, + intent_id: "intent-red-1", + policy: "intent", + skill: "intercept", + target: "object:red", + }); + assert.equal(motor.queued, true); + const intent = fixture.runtime!.act( + { + principal: "principal-red", + decisionToken: fixture.red.token, + }, + runtimeActEnvelope("intent-red-1", { + affordance: "world://pitch/affordance/kick", + target: "world://pitch/entity/ball", + input: { force: 1 }, + }), + ); + assert.equal(intent.disposition, "queued"); + const drained = readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + assert.deepEqual(drained, { action_results: 2, events: 2, tick: 0 }); + assert.equal(drained.raw_step.action_results.length, 2); + assert.equal(drained.raw_step.events.length, 2); + assert.deepEqual(controller.inspect(), [{ + accepted: true, + action: "move", + actor: "object:red", + controller_id: "red-body", + controller_version: "test-v1", + input: { x: 1 }, + intent_id: "intent-red-1", + policy: "intent", + skill: "intercept", + target: "object:red", + act_id: "controller:red-body:0", + apply_tick: 0, + event_kinds: ["body.motion", "impact"], + sequence: 1, + status: "applied", + }]); + const result = runtimeActionResultLedger(fixture.runtime!)!.read( + "principal-red", + { version: "simfile.world-action-result-page-request.v1" }, + ).results[0]!; + assert.equal(result.status, "applied"); + assert.deepEqual( + (result as Extract).caused_effect_ids, + ["world-effect-1"], + ); +}); + +test("controller authority is host-only and rejects malformed provenance", () => { + const fixture = runtimeFixtureWithHooks({}); + const controller = readWorldRuntimeControllerAuthority(fixture.runtime!)!; + assert.equal("controller" in fixture.runtime!, false); + assert.equal("readWorldRuntimeControllerAuthority" in worldBarrel, false); + assert.equal("readWorldRuntimeControllerAuthority" in packageBarrel, false); + assert.throws(() => controller.queue({ + action: "move", + actor: "object:red", + controller_id: " red-body", + controller_version: "test-v1", + input: {}, + policy: "default", + skill: "seek", + target: "object:red", + })); + assert.deepEqual(controller.inspect(), []); +}); + +test("controller settlement preserves terminal commitment outcomes", () => { + const fixture = runtimeFixtureWithHooks({}); + const controller = readWorldRuntimeControllerAuthority(fixture.runtime!)!; + const outcomes = [{ + commitment_id: "commitment:red:1", + declaration_action_sequence: 1, + outcome: "expired" as const, + participant: "object:red", + provenance: "mechanical" as const, + tick: 3, + }]; + assert.deepEqual(controller.settle({ + action_results: [], + commitment_outcomes: outcomes, + events: [], + tick: 3, + }).commitment_outcomes, outcomes); +}); + +test("retryable mechanics failures preserve the queued controller action", () => { + let fail = true; + const fixture = runtimeFixtureWithHooks({ + step: (input) => { + if (fail) { + fail = false; + throw new Error("provider retry"); + } + const value = input as { + readonly actions: readonly { readonly sequence: number }[]; + readonly tick: number; + }; + return { + action_results: value.actions.map(({ sequence }) => ({ + accepted: true, + sequence, + })), + events: [], + tick: value.tick, + }; + }, + }); + const controller = readWorldRuntimeControllerAuthority(fixture.runtime!)!; + const action = { + action: "move", + actor: "object:red", + controller_id: "red-body", + controller_version: "test-v1", + input: {}, + policy: "default", + skill: "seek", + target: "object:red", + } as const; + const receipt = controller.queue(action); + assert.deepEqual(controller.queue(action), receipt); + assert.throws( + () => readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(), + /checked step/u, + ); + assert.equal(controller.inspect()[0]?.status, "queued"); + assert.equal( + readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics().tick, + 0, + ); + assert.equal(controller.inspect()[0]?.status, "applied"); +}); diff --git a/src/world/controllerAuthority.ts b/src/world/controllerAuthority.ts new file mode 100644 index 0000000..c5f4ecc --- /dev/null +++ b/src/world/controllerAuthority.ts @@ -0,0 +1,209 @@ +import { isDeepStrictEqual, types } from "node:util"; + +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import type { DynamicsSession } from "../dynamics/session.js"; +import type { + DynamicsActionQueueReceipt, + DynamicsJsonObject, + DynamicsStepResult, +} from "../dynamics/types.js"; + +export interface WorldControllerAction { + readonly action: string; + readonly actor: string; + readonly controller_id: string; + readonly controller_version: string; + readonly input: DynamicsJsonObject; + readonly intent_id?: string; + readonly policy: "default" | "intent"; + readonly skill: string; + readonly target: string; +} + +export interface WorldControllerRecord extends WorldControllerAction { + readonly accepted?: boolean; + readonly act_id: string; + readonly apply_tick: number; + readonly code?: string; + readonly event_kinds: readonly string[]; + readonly sequence?: number; + readonly status: "queued" | "applied" | "rejected"; +} + +export interface WorldRuntimeControllerAuthority { + inspect(): readonly WorldControllerRecord[]; + queue(action: WorldControllerAction): DynamicsActionQueueReceipt; + settle(step: DynamicsStepResult): DynamicsStepResult; +} + +type RuntimeOperation = { + enter(): void; + leave(): void; + close(): void; +}; + +const authorities = new WeakMap(); +const issued = new WeakSet(); + +const text = (value: unknown, maximum = 256): value is string => + typeof value === "string" && value.length > 0 && value.length <= maximum + && value === value.trim(); + +const plainInput = (value: unknown): value is DynamicsJsonObject => + value !== null && typeof value === "object" && !Array.isArray(value) + && !types.isProxy(value as object) + && Object.getPrototypeOf(value) === Object.prototype; + +const parseAction = (value: WorldControllerAction): WorldControllerAction => { + if (value === null || typeof value !== "object" || types.isProxy(value) + || Object.getPrototypeOf(value) !== Object.prototype + || !text(value.action) || !text(value.actor) || !text(value.controller_id) + || !text(value.controller_version) || !text(value.skill) + || !text(value.target) || !plainInput(value.input) + || value.policy !== "default" && value.policy !== "intent" + || value.intent_id !== undefined && !text(value.intent_id)) { + throw new TypeError("invalid world controller action"); + } + return structuredClone(value); +}; + +const cloneRecord = (record: WorldControllerRecord): WorldControllerRecord => + Object.freeze(structuredClone(record)); + +export const readWorldRuntimeControllerAuthority = ( + runtime: unknown, +): WorldRuntimeControllerAuthority | undefined => + runtime !== null && typeof runtime === "object" ? authorities.get(runtime) : undefined; + +/** @internal Issues one host-only controller lane for a composed world runtime. */ +export const createWorldRuntimeControllerAuthority = ( + runtime: object, + dependencies: Readonly<{ + dynamics: DynamicsSession; + operation: RuntimeOperation; + }>, +): WorldRuntimeControllerAuthority => { + if (authorities.has(runtime)) throw new Error("world controller authority already issued"); + const records: WorldControllerRecord[] = []; + const pending = new Map(); + const queue = (raw: WorldControllerAction): DynamicsActionQueueReceipt => { + const action = parseAction(raw); + dependencies.operation.enter(); + try { + const applyTick = dependencies.dynamics.nextTick; + const actId = `controller:${action.controller_id}:${applyTick}`; + const existing = records.find((record) => record.act_id === actId); + if (existing !== undefined) { + const priorAction = { + action: existing.action, + actor: existing.actor, + controller_id: existing.controller_id, + controller_version: existing.controller_version, + input: existing.input, + ...(existing.intent_id === undefined ? {} : { intent_id: existing.intent_id }), + policy: existing.policy, + skill: existing.skill, + target: existing.target, + }; + if (!isDeepStrictEqual(action, priorAction)) { + throw new Error("world controller action id conflict"); + } + return Object.freeze({ act_id: existing.act_id, apply_tick: existing.apply_tick, + queued: existing.status === "queued", ...(existing.sequence === undefined ? {} : { sequence: existing.sequence }), + ...(existing.code === undefined ? {} : { code: existing.code as DynamicsActionQueueReceipt["code"] }) }); + } + if (records.length >= DYNAMICS_LIMITS.retained_action_records) { + throw new Error("world controller record capacity reached"); + } + const receipt = dependencies.dynamics.queueAction({ + act_id: actId, + action: action.action, + actor: action.actor, + at_tick: applyTick, + input: action.input, + origin: "controller", + principal_id: `controller:${action.controller_id}`, + target: action.target, + }); + const index = records.length; + records.push(Object.freeze({ + ...action, + act_id: actId, + apply_tick: receipt.apply_tick, + event_kinds: Object.freeze([]), + ...(receipt.sequence === undefined ? {} : { sequence: receipt.sequence }), + status: receipt.queued ? "queued" : "rejected", + ...(receipt.code === undefined ? {} : { code: receipt.code }), + })); + if (receipt.queued && receipt.sequence !== undefined) { + pending.set(receipt.sequence, index); + } + return Object.freeze({ ...receipt }); + } finally { + dependencies.operation.leave(); + } + }; + const settle = (step: DynamicsStepResult): DynamicsStepResult => { + const controllerSequences = new Set(); + for (const result of step.action_results) { + const index = pending.get(result.sequence); + if (index === undefined) continue; + const record = records[index]!; + if (result.origin !== "controller" + || result.principal_id !== `controller:${record.controller_id}` + || result.act_id !== record.act_id || result.action !== record.action + || result.actor !== record.actor || result.target !== record.target + || result.apply_tick !== record.apply_tick) { + dependencies.operation.close(); + throw new Error("world controller mechanics result mismatch"); + } + const eventKinds = step.events + .filter((event) => event.cause_action_sequences.includes(result.sequence)) + .map((event) => event.kind); + records[index] = Object.freeze({ + ...record, + accepted: result.accepted, + ...(result.code === undefined ? {} : { code: result.code }), + event_kinds: Object.freeze(eventKinds), + status: result.accepted ? "applied" : "rejected", + }); + pending.delete(result.sequence); + controllerSequences.add(result.sequence); + } + const missing = [...pending.entries()].find(([, index]) => + records[index]?.apply_tick === step.tick); + if (missing !== undefined) { + dependencies.operation.close(); + throw new Error("world controller mechanics result missing"); + } + return Object.freeze({ + tick: step.tick, + action_results: step.action_results.filter((result) => + !controllerSequences.has(result.sequence)), + ...(step.commitment_outcomes === undefined ? {} : { + commitment_outcomes: step.commitment_outcomes, + }), + events: step.events.flatMap((event) => { + const agentCauses = event.cause_action_sequences.filter((sequence) => + !controllerSequences.has(sequence)); + return agentCauses.length === 0 ? [] : [Object.freeze({ + ...event, + cause_action_sequences: agentCauses, + })]; + }), + }); + }; + const authority: WorldRuntimeControllerAuthority = Object.freeze({ + inspect: () => Object.freeze(records.map(cloneRecord)), + queue, + settle, + }); + issued.add(authority); + authorities.set(runtime, authority); + return authority; +}; + +export const isWorldRuntimeControllerAuthority = ( + value: unknown, +): value is WorldRuntimeControllerAuthority => + value !== null && typeof value === "object" && issued.has(value); diff --git a/src/world/decisionClaim.test.ts b/src/world/decisionClaim.test.ts new file mode 100644 index 0000000..c923ad6 --- /dev/null +++ b/src/world/decisionClaim.test.ts @@ -0,0 +1,80 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createDecisionRegistryForTesting } from "./decisionRegistry.js"; +import { + activateWorldDecisionClaim, + claimWorldDecision, + createWorldDecisionClaimAuthority, + enableWorldDecisionClaim, + registerWorldDecisionClaimAuthority, + WORLD_DECISION_CLAIM_VALIDITY_TICKS, +} from "./decisionClaim.js"; +import { WorldRuntimeError } from "./ledger.js"; +import { runtimeFixture } from "./runtime.test-helper.js"; + +const denied = (error: unknown): boolean => error instanceof WorldRuntimeError + && error.code === "world_runtime_denied" + && !String(error).includes("opaque"); + +test("claim authority is activation-gated, single-owner, replay-safe, and replaceable after expiry", () => { + let tick = 4; + let entropy = 1; + const registry = createDecisionRegistryForTesting({ + runId: "run-claim", + worldInstanceId: "world-claim", + tokenDigestKey: new Uint8Array(32).fill(7), + }, { randomBytes: () => new Uint8Array(32).fill(entropy++) }); + const authority = createWorldDecisionClaimAuthority({ + decisionRegistry: registry, + principals: new Set(["principal-red", "principal-blue"]), + readTick: () => tick, + }); + const runtime = {}; + registerWorldDecisionClaimAuthority(runtime, authority); + assert.throws(() => claimWorldDecision(runtime, "principal-red", { + request_id: "claim-red-1", wake_id: "schedule-red-1", + }), denied); + enableWorldDecisionClaim(runtime); + assert.throws(() => claimWorldDecision(runtime, "principal-red", { + request_id: "claim-red-1", wake_id: "schedule-red-1", + }), denied); + activateWorldDecisionClaim(runtime); + const first = claimWorldDecision(runtime, "principal-red", { + request_id: "claim-red-1", wake_id: "schedule-red-1", + }); + assert.equal(first.issued_at_tick, 4); + assert.equal(first.valid_through_tick, 4 + WORLD_DECISION_CLAIM_VALIDITY_TICKS); + assert.match(first.decision_token, /^[A-Za-z0-9_-]+$/u); + for (const [principal, request] of [ + ["principal-red", { request_id: "claim-red-1", wake_id: "schedule-red-1" }], + ["principal-blue", { request_id: "claim-blue-1", wake_id: "schedule-red-1" }], + ["principal-red", { request_id: "claim-red-2", wake_id: "schedule-red-2" }], + ["principal-foreign", { request_id: "claim-foreign", wake_id: "schedule-foreign" }], + ] as const) assert.throws(() => claimWorldDecision(runtime, principal, request), denied); + + tick = first.valid_through_tick + 1; + const replacement = claimWorldDecision(runtime, "principal-red", { + request_id: "claim-red-3", wake_id: "schedule-red-3", + }); + assert.notEqual(replacement.decision_token, first.decision_token); + assert.equal(registry.inspect().decisions.find((item) => item.decisionId === first.decision_id)?.status, "expired"); + assert.equal(registry.inspect().decisions.find((item) => item.decisionId === replacement.decision_id)?.status, "active"); +}); + +test("the issued runtime keeps the base six-operation shape while the extension authenticates its registry", () => { + const fixture = runtimeFixture(); + fixture.decisionRegistry.consumeForAct({ principal: "principal-red", runId: "run-1", + worldInstanceId: "instance-1", token: fixture.red.token, atTick: 0 }); + assert.deepEqual(Object.keys(fixture.runtime), [ + "status", "capabilities", "observe", "affordances", "ledger", "act", + ]); + enableWorldDecisionClaim(fixture.runtime); + activateWorldDecisionClaim(fixture.runtime); + const claim = claimWorldDecision(fixture.runtime, "principal-red", { + request_id: "runtime-claim-1", wake_id: "schedule-runtime-1", + }); + const status = fixture.runtime.status({ principal: "principal-red", + decisionToken: claim.decision_token }); + assert.equal(status.decision.id, claim.decision_id); +}); diff --git a/src/world/decisionClaim.ts b/src/world/decisionClaim.ts new file mode 100644 index 0000000..334950a --- /dev/null +++ b/src/world/decisionClaim.ts @@ -0,0 +1,120 @@ +import type { DecisionRegistry } from "./decisionRegistry.js"; +import { WorldRuntimeError } from "./ledger.js"; +import { types } from "node:util"; + +export const WORLD_DECISION_CLAIM_CAPABILITY = + "simfile.world-decision-claim.v1" as const; +export const WORLD_DECISION_CLAIM_VALIDITY_TICKS = 30_000; + +export interface WorldDecisionClaimResult { + readonly decision_id: string; + readonly decision_token: string; + readonly issued_at_tick: number; + readonly valid_through_tick: number; +} + +interface CreateWorldDecisionClaimAuthorityInput { + readonly decisionRegistry: DecisionRegistry; + readonly principals: ReadonlySet; + readonly readTick: () => number; +} + +interface WorldDecisionClaimAuthority { + activate(): void; + enable(): void; + claim(principal: string, request: unknown): WorldDecisionClaimResult; +} + +const authorities = new WeakMap(); +const identifier = (value: unknown): value is string => typeof value === "string" + && value.length > 0 && value.length <= 256 && value === value.trim(); +const request = (value: unknown): Readonly<{ requestId: string; wakeId: string }> | undefined => { + try { + if (value === null || typeof value !== "object" || Array.isArray(value) || types.isProxy(value)) return undefined; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) return undefined; + const keys = Reflect.ownKeys(value); + if (keys.length !== 2 || !keys.includes("request_id") || !keys.includes("wake_id")) return undefined; + const requestId = Object.getOwnPropertyDescriptor(value, "request_id"); + const wakeId = Object.getOwnPropertyDescriptor(value, "wake_id"); + return requestId?.enumerable && "value" in requestId && identifier(requestId.value) + && wakeId?.enumerable && "value" in wakeId && identifier(wakeId.value) + ? Object.freeze({ requestId: requestId.value, wakeId: wakeId.value }) + : undefined; + } catch { return undefined; } +}; +const denied = (): never => { throw new WorldRuntimeError("world_runtime_denied"); }; + +export const createWorldDecisionClaimAuthority = ( + input: CreateWorldDecisionClaimAuthorityInput, +): WorldDecisionClaimAuthority => { + let enabled = false; + let active = false; + let operating = false; + const requestIds = new Set(); + const wakeIds = new Set(); + return Object.freeze({ + enable: (): void => { enabled = true; }, + activate: (): void => { + if (!enabled) return denied(); + active = true; + }, + claim: (principal: string, value: unknown): WorldDecisionClaimResult => { + if (!enabled || !active || operating || !input.principals.has(principal)) return denied(); + const parsed = request(value); + if (parsed === undefined || requestIds.has(parsed.requestId) || wakeIds.has(parsed.wakeId)) return denied(); + operating = true; + try { + const issuedTick = input.readTick(); + if (!Number.isSafeInteger(issuedTick) || issuedTick < 0) return denied(); + const validThroughTick = Math.min( + Number.MAX_SAFE_INTEGER, + issuedTick + WORLD_DECISION_CLAIM_VALIDITY_TICKS, + ); + const minted = input.decisionRegistry.mint({ + principal, + issuedTick, + validThroughTick, + }); + requestIds.add(parsed.requestId); + wakeIds.add(parsed.wakeId); + return Object.freeze({ + decision_id: minted.decisionId, + decision_token: minted.token, + issued_at_tick: minted.issuedTick, + valid_through_tick: minted.validThroughTick, + }); + } catch { return denied(); } + finally { operating = false; } + }, + }); +}; + +export const registerWorldDecisionClaimAuthority = ( + runtime: object, + authority: WorldDecisionClaimAuthority, +): void => { authorities.set(runtime, authority); }; + +/** @internal Sidecar host seam; deliberately absent from public package barrels. */ +export const enableWorldDecisionClaim = (runtime: object): void => { + const authority = authorities.get(runtime); + if (authority === undefined) return denied(); + authority.enable(); +}; + +/** @internal Authenticated topology activation seam. */ +export const activateWorldDecisionClaim = (runtime: object): void => { + const authority = authorities.get(runtime); + if (authority === undefined) return denied(); + authority.activate(); +}; + +export const claimWorldDecision = ( + runtime: object, + principal: string, + request: unknown, +): WorldDecisionClaimResult => { + const authority = authorities.get(runtime); + if (authority === undefined) return denied(); + return authority.claim(principal, request); +}; diff --git a/src/world/decisionRegistry.test.ts b/src/world/decisionRegistry.test.ts new file mode 100644 index 0000000..002cefd --- /dev/null +++ b/src/world/decisionRegistry.test.ts @@ -0,0 +1,399 @@ +import assert from "node:assert/strict"; +import { createHmac } from "node:crypto"; +import test from "node:test"; +import * as world from "./index.js"; +import { DecisionRegistryError, createDecisionRegistry, createDecisionRegistryForTesting } from "./decisionRegistry.js"; +import type { DecisionRegistry, DecisionRegistryErrorCode } from "./decisionRegistry.js"; + +const key = () => new Uint8Array(Array.from({ length: 32 }, (_, index) => index)); +const config = () => ({ runId: "run-1", worldInstanceId: "world-1", tokenDigestKey: key() }); +const bytes = (value: number) => new Uint8Array(32).fill(value); +const factory = (...values: number[]) => { let index = 0; return createDecisionRegistryForTesting(config(), { randomBytes: () => bytes(values[index++] ?? 255) }); }; +const admission = (token: string, atTick: number, overrides = {}) => + ({ principal: "agent-1", runId: "run-1", worldInstanceId: "world-1", token, atTick, ...overrides }); +const fails = (fn: () => unknown, code: string) => assert.throws(fn, (value: unknown) => value instanceof DecisionRegistryError && value.code === code); +const failsAtomically = (registry: DecisionRegistry, fn: () => unknown, code: string) => { const before = registry.inspect(); + fails(fn, code); + assert.deepEqual(registry.inspect(), before); }; + +test("mints deterministic canonical tokens and retains only the specified HMAC digest", () => { + const registry = factory(7); + const minted = registry.mint({ principal: "agent-1", issuedTick: 4, validThroughTick: 8 }); + const expectedDigest = `sha256:${createHmac("sha256", key()).update("simfile.decision-token.v1\0run-1\0").update(minted.token).digest("hex")}`; + assert.equal(minted.token, Buffer.from(bytes(7)).toString("base64url")); + assert.equal(registry.inspect().decisions[0]?.tokenDigest, expectedDigest); + assert.match(minted.decisionId, /^decision-000000000001$/); + assert.equal(Buffer.from(minted.token, "base64url").byteLength, 32); + assert.equal((world as Record).createDecisionRegistryForTesting, undefined); +}); + +test("production entropy and copied configuration key do not expose mutable key material", () => { + const mutableKey = key(); + const registry = createDecisionRegistry({ runId: "run-1", worldInstanceId: "world-1", tokenDigestKey: mutableKey }); + const minted = registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 1 }); + const before = registry.inspect().decisions[0]?.tokenDigest; + mutableKey.fill(99); + assert.equal(registry.admitRead(admission(minted.token, 0)).tokenDigest, before); + assert.equal(Buffer.from(minted.token, "base64url").byteLength, 32); + assert.equal(Buffer.from(minted.token, "base64url").toString("base64url"), minted.token); +}); + +test("rejects malformed inputs, entropy failures, and collision exhaustion without allocating ids", () => { + for (const bad of [undefined, null, {}, { ...config(), tokenDigestKey: new Uint8Array(31) }, { ...config(), runId: " " }, { ...config(), worldInstanceId: "" }]) { + fails(() => createDecisionRegistry(bad), "invalid_config"); + } + for (const entropy of [() => undefined as unknown as Uint8Array, () => new Uint8Array(31), () => ({} as Uint8Array), () => { throw new Error("private"); }]) { + const registry = createDecisionRegistryForTesting(config(), { randomBytes: entropy }); + fails(() => registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }), "entropy_failure"); + assert.equal(registry.inspect().nextDecisionSequence, 1); + } + const registry = factory(3, ...Array(16).fill(3), 4); + registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + fails(() => registry.mint({ principal: "agent-2", issuedTick: 1, validThroughTick: 1 }), "entropy_failure"); + assert.equal(registry.inspect().nextDecisionSequence, 2); + assert.equal(registry.mint({ principal: "agent-2", issuedTick: 2, validThroughTick: 2 }).decisionId, "decision-000000000002"); + const malformed = factory(1); + for (const bad of [{}, { principal: "", issuedTick: 0, validThroughTick: 0 }, + { principal: "x", issuedTick: -1, validThroughTick: 0 }, { principal: "x", issuedTick: 1, validThroughTick: 0 }, + { principal: "x", issuedTick: Number.MAX_SAFE_INTEGER + 1, validThroughTick: 2 }]) { + fails(() => malformed.mint(bad), "invalid_input"); + } +}); + +test("core parsers reject proxies and accessors without running hostile code or leaking values", () => { + const secret = Buffer.from(bytes(44)).toString("base64url"); + const configAccessor = () => { + const value = config() as Record; + Object.defineProperty(value, "runId", { + enumerable: true, + get: () => { throw new Error(secret); }, + }); + return value; + }; + const optionsAccessor = () => { + const value: Record = {}; + Object.defineProperty(value, "randomBytes", { + enumerable: true, + get: () => { throw new Error(secret); }, + }); + return value; + }; + for (const makeConfig of [configAccessor, () => new Proxy(config(), { getPrototypeOf: () => { throw new Error(secret); } })]) { + assert.throws(() => createDecisionRegistry(makeConfig()), (value: unknown) => + value instanceof DecisionRegistryError && value.code === "invalid_config" && !String(value).includes(secret)); + } + for (const options of [optionsAccessor(), new Proxy({ randomBytes: () => bytes(1) }, { getPrototypeOf: () => { throw new Error(secret); } })]) { + assert.throws(() => createDecisionRegistryForTesting(config(), options), (value: unknown) => + value instanceof DecisionRegistryError && value.code === "invalid_config" && !String(value).includes(secret)); + } + const registry = factory(1, 2); + const minted = registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 1 }); + const accessor: Record = { issuedTick: 0, validThroughTick: 0 }; + Object.defineProperty(accessor, "principal", { enumerable: true, get: () => { throw new Error(secret); } }); + const hostileAdmission: Record = { principal: "agent-1", runId: "run-1", worldInstanceId: "world-1", atTick: 0 }; + Object.defineProperty(hostileAdmission, "token", { enumerable: true, get: () => { throw new Error(secret); } }); + for (const operation of [() => registry.mint(accessor), () => registry.admitRead(hostileAdmission), () => registry.mint(new Proxy({ principal: "agent-2", issuedTick: 0, validThroughTick: 0 }, { getPrototypeOf: () => { throw new Error(secret); } })), + () => registry.admitRead(new Proxy(admission(minted.token, 0), { getPrototypeOf: () => { throw new Error(secret); } }))]) { + const before = registry.snapshot(); + assert.throws(operation, (value: unknown) => value instanceof DecisionRegistryError && value.code === "invalid_input" && !String(value).includes(secret)); + assert.deepEqual(registry.snapshot(), before); + } + assert.equal(registry.admitRead(admission(minted.token, 0)).status, "active"); +}); + +test("entropy reentrancy is redacted, atomic, and clears every mutator guard", () => { + const token = Buffer.from(bytes(9)).toString("base64url"); + const mutators: readonly ((registry: DecisionRegistry) => void)[] = [ + (registry) => { registry.mint({ principal: "nested", issuedTick: 0, validThroughTick: 0 }); }, + (registry) => { registry.admitRead(admission(token, 0)); }, + (registry) => { registry.consumeForAct(admission(token, 0)); }, + (registry) => { registry.beginCutoff(0); }, + (registry) => { registry.closeAdmissions(0); }, + (registry) => { registry.finalize(0); }, + (registry) => { registry.restore({}); }, + ]; + for (const caught of [false, true]) { + for (const attack of mutators) { + let registry: DecisionRegistry; + let hostile = true; + let caughtCode: string | undefined; + registry = createDecisionRegistryForTesting(config(), { + randomBytes: () => { + if (hostile) { + hostile = false; + if (caught) { + try { + attack(registry); + } catch (cause) { + caughtCode = cause instanceof DecisionRegistryError ? cause.code : undefined; + } + } else { + attack(registry); + } + } + return bytes(7); + }, + }); + const before = registry.snapshot(); + assert.throws( + () => registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }), + (cause: unknown) => cause instanceof DecisionRegistryError && cause.code === "entropy_failure" && + !String(cause).includes(token), + ); + assert.equal(caught ? caughtCode : undefined, caught ? "invalid_input" : undefined); + assert.deepEqual(registry.snapshot(), before); + assert.equal(registry.snapshot().nextDecisionSequence, 1); + assert.equal(registry.snapshot().decisions.length, 0); + assert.equal( + registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }).decisionId, + "decision-000000000001", + ); + } + } +}); + +test("keeps every rejected operation state-atomic", () => { + const registry = factory(1, 2); + const minted = registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 4 }); + failsAtomically(registry, () => registry.admitRead({}), "invalid_input"); + failsAtomically(registry, () => registry.mint({}), "invalid_input"); + failsAtomically(registry, () => registry.beginCutoff(-1), "invalid_input"); + failsAtomically(registry, () => registry.admitRead(admission(Buffer.from(bytes(9)).toString("base64url"), 1)), "token_invalid"); + failsAtomically(registry, () => registry.admitRead(admission(minted.token, 1, { principal: "agent-2" })), "token_invalid"); + failsAtomically(registry, () => registry.admitRead(admission(minted.token, 1, { runId: "run-2" })), "token_invalid"); + failsAtomically(registry, () => registry.admitRead(admission(minted.token, 1, { worldInstanceId: "world-2" })), "token_invalid"); + failsAtomically(registry, () => registry.mint({ principal: "agent-1", issuedTick: 1, validThroughTick: 4 }), "active_decision_exists"); + registry.admitRead(admission(minted.token, 1)); + failsAtomically(registry, () => registry.mint({ principal: "agent-2", issuedTick: 0, validThroughTick: 0 }), "tick_regression"); + failsAtomically(registry, () => registry.closeAdmissions(1), "invalid_transition"); + registry.consumeForAct(admission(minted.token, 1)); + failsAtomically(registry, () => registry.consumeForAct(admission(minted.token, 1)), "token_consumed"); + registry.beginCutoff(1); + failsAtomically(registry, () => registry.beginCutoff(1), "invalid_transition"); + failsAtomically(registry, () => registry.mint({ principal: "agent-2", issuedTick: 1, validThroughTick: 1 }), "mint_closed"); + + const active = factory(3); + active.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 4 }); + active.beginCutoff(1); + failsAtomically(active, () => active.closeAdmissions(1), "active_decisions_remain"); + + const entropy = createDecisionRegistryForTesting(config(), { randomBytes: () => { throw new Error("private"); } }); + failsAtomically(entropy, () => entropy.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }), "entropy_failure"); + const collisions = factory(4, ...Array(16).fill(4)); + collisions.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + failsAtomically(collisions, () => collisions.mint({ principal: "agent-2", issuedTick: 1, validThroughTick: 1 }), "entropy_failure"); +}); + +test("validates bindings, canonical tokens, monotonic ticks, inclusive expiry, and first-act consumption", () => { + const registry = factory(1, 2, 3); + const minted = registry.mint({ principal: "agent-1", issuedTick: 2, validThroughTick: 4 }); + for (const [bad, code] of [[admission(minted.token, 2, { principal: "agent-2" }), "token_invalid"], + [admission(minted.token, 2, { runId: "run-2" }), "token_invalid"], + [admission(minted.token, 2, { worldInstanceId: "world-2" }), "token_invalid"], + [admission(Buffer.from(bytes(99)).toString("base64url"), 2), "token_invalid"], [admission("bad", 2), "invalid_input"], + [admission(minted.token, -1), "invalid_input"]] as const) { + fails(() => registry.admitRead(bad), code); + } + assert.equal(registry.admitRead(admission(minted.token, 4)).status, "active"); + assert.equal(registry.consumeForAct(admission(minted.token, 4)).status, "consumed"); + fails(() => registry.consumeForAct(admission(minted.token, 4)), "token_consumed"); + assert.equal(registry.inspect().decisions[0]?.status, "consumed"); + fails(() => registry.admitRead(admission(minted.token, 3)), "tick_regression"); + const expiry = registry.mint({ principal: "agent-1", issuedTick: 5, validThroughTick: 5 }); + fails(() => registry.admitRead(admission(expiry.token, 6)), "token_expired"); + assert.equal(registry.inspect().decisions[1]?.status, "expired"); +}); + +test("repeated reads stay active while expired act consumption performs the expiry transition", () => { + const registry = factory(1); + const minted = registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + assert.equal(registry.admitRead(admission(minted.token, 0)).status, "active"); + assert.equal(registry.admitRead(admission(minted.token, 0)).status, "active"); + fails(() => registry.consumeForAct(admission(minted.token, 1)), "token_expired"); + assert.deepEqual(registry.inspect().decisions.map(({ status }) => status), ["expired"]); +}); + +test("permits one active decision per principal and preserves consumed or expired history", () => { + const registry = factory(1, 2, 3); + const first = registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 2 }); + fails(() => registry.mint({ principal: "agent-1", issuedTick: 1, validThroughTick: 2 }), "active_decision_exists"); + registry.consumeForAct(admission(first.token, 1)); + registry.mint({ principal: "agent-1", issuedTick: 2, validThroughTick: 2 }); + const later = registry.mint({ principal: "agent-2", issuedTick: 3, validThroughTick: 3 }); + fails(() => registry.admitRead(admission(later.token, 4, { principal: "agent-2" })), "token_expired"); + registry.mint({ principal: "agent-2", issuedTick: 4, validThroughTick: 4 }); + assert.deepEqual(registry.inspect().decisions.map((record) => record.status), ["consumed", "expired", "expired", "active"]); +}); + +test("enforces C/A/F, records frontiers, and preserves pre-cutoff admission", () => { + const registry = factory(1, 2); + const minted = registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 3 }); + fails(() => registry.closeAdmissions(1), "invalid_transition"); + fails(() => registry.finalize(1), "invalid_transition"); + registry.beginCutoff(1); + fails(() => registry.mint({ principal: "agent-2", issuedTick: 1, validThroughTick: 1 }), "mint_closed"); + assert.equal(registry.admitRead(admission(minted.token, 1)).decisionId, minted.decisionId); + fails(() => registry.closeAdmissions(2), "active_decisions_remain"); + registry.consumeForAct(admission(minted.token, 2)); + registry.closeAdmissions(2); + fails(() => registry.admitRead(admission(minted.token, 2)), "admissions_closed"); + fails(() => registry.mint({ principal: "agent-2", issuedTick: 2, validThroughTick: 2 }), "mint_closed"); + fails(() => registry.beginCutoff(2), "invalid_transition"); + registry.finalize(3); + fails(() => registry.finalize(3), "invalid_transition"); + fails(() => registry.mint({ principal: "agent-2", issuedTick: 3, validThroughTick: 3 }), "mint_closed"); + fails(() => registry.admitRead(admission(minted.token, 3)), "admissions_closed"); + const view = registry.inspect(); + assert.deepEqual([view.cutoffTick, view.admissionsClosedTick, view.finalizedTick], [1, 2, 3]); +}); + +test("cutoff can close only after expiry sweeps its final active decision", () => { + const registry = factory(1); + registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + registry.beginCutoff(0); + fails(() => registry.closeAdmissions(0), "active_decisions_remain"); + registry.closeAdmissions(1); + assert.equal(registry.inspect().decisions[0]?.status, "expired"); +}); + +test("cutoff sweeps expiry as part of its successful transition", () => { + const registry = factory(1); + registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + registry.beginCutoff(1); + const inspection = registry.inspect(); + assert.equal(inspection.lastTick, 1); + assert.equal(inspection.cutoffTick, 1); + assert.equal(inspection.decisions[0]?.status, "expired"); +}); + +test("rejects act and read admission after both admissions closure and finalization", () => { + const registry = factory(1); + const minted = registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + registry.beginCutoff(1); + registry.closeAdmissions(1); + for (const operation of [registry.admitRead, registry.consumeForAct]) { + failsAtomically(registry, () => operation.call(registry, admission(minted.token, 1)), "admissions_closed"); + } + registry.finalize(2); + for (const operation of [registry.admitRead, registry.consumeForAct]) { + failsAtomically(registry, () => operation.call(registry, admission(minted.token, 2)), "admissions_closed"); + } +}); + +test("requests exactly 32 bytes of entropy for every token draw", () => { + const sizes: number[] = []; + const registry = createDecisionRegistryForTesting(config(), { + randomBytes: (size: number) => { + sizes.push(size); + return bytes(6); + }, + }); + registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + fails(() => registry.mint({ principal: "agent-2", issuedTick: 1, validThroughTick: 1 }), "entropy_failure"); + assert.equal(sizes.length, 17); + assert(sizes.every((size) => size === 32)); +}); + +test("keeps every exported error code and redacted message stable", () => { + const expected: readonly (readonly [DecisionRegistryErrorCode, string])[] = [ + ["invalid_config", "Invalid decision registry configuration."], + ["invalid_input", "Invalid decision registry input."], + ["tick_regression", "Decision registry tick regressed."], + ["mint_closed", "Decision minting is closed."], + ["active_decision_exists", "An active decision already exists for this principal."], + ["entropy_failure", "Decision token entropy failed."], + ["token_invalid", "Decision token admission failed."], + ["token_expired", "Decision token has expired."], + ["token_consumed", "Decision token has already been consumed."], + ["admissions_closed", "Decision admissions are closed."], + ["invalid_transition", "Invalid decision registry phase transition."], + ["active_decisions_remain", "Active decisions remain."], + ["invalid_snapshot", "Invalid decision registry snapshot."], + ["restore_not_pristine", "Decision registry restore requires a pristine registry."], + ["sequence_exhausted", "Decision registry sequence is exhausted."], + ]; + for (const [code, message] of expected) { + const failure = new DecisionRegistryError(code); + assert.equal(failure.code, code); + assert.equal(failure.message, message); + } +}); + +test("inspects non-ASCII principals in issued decision-id order without locale collation", () => { + const registry = factory(1, 2, 3); + const principals = ["\u00e9clair", "\u3042\u3044", "\u00c5ngstr\u00f6m"]; + for (const principal of principals) registry.mint({ principal, issuedTick: 0, validThroughTick: 0 }); + const descriptor = Object.getOwnPropertyDescriptor(String.prototype, "localeCompare"); + assert(descriptor); + Object.defineProperty(String.prototype, "localeCompare", { ...descriptor, value: () => { throw new Error("locale used"); } }); + try { + const decisions = registry.inspect().decisions; + assert.deepEqual(decisions.map(({ decisionId }) => decisionId), [ + "decision-000000000001", "decision-000000000002", "decision-000000000003", + ]); + assert.deepEqual(decisions.map(({ principal }) => principal), principals); + } finally { + Object.defineProperty(String.prototype, "localeCompare", descriptor); + } +}); + +test("inspection and admissions are frozen and every public value is redacted", () => { + const registry = factory(9); + const minted = registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 1 }); + const admitted = registry.admitRead(admission(minted.token, 0)); + const inspection = registry.inspect(); + assert(Object.isFrozen(admitted)); + assert(Object.isFrozen(inspection)); + assert(Object.isFrozen(inspection.decisions)); + assert(Object.isFrozen(inspection.decisions[0])); + assert.throws(() => { (admitted as { status: string }).status = "expired"; }); + for (const value of [admitted, inspection, registry.inspect()]) assert(!JSON.stringify(value).includes(minted.token)); + try { registry.admitRead(admission(minted.token, 2)); } catch (error) { + assert(!String(error).includes(minted.token)); + } +}); + +test("peekReadAdmission validates without mutating or exposing admission secrets", () => { + const assertUnchanged = (registry: DecisionRegistry, operation: () => unknown, code?: string) => { + const before = registry.snapshot(); + if (code === undefined) operation(); else fails(operation, code); + assert.deepEqual(registry.snapshot(), before); + }; + const registry = factory(1, 2); + const minted = registry.mint({ principal: "agent-1", issuedTick: 2, validThroughTick: 4 }); + let result: ReturnType | undefined; + assertUnchanged(registry, () => { result = registry.peekReadAdmission(admission(minted.token, 2)); }); + assert.deepEqual(result, { decisionId: minted.decisionId, status: "active", issuedTick: 2, validThroughTick: 4, phase: "open" }); + assert(result && Object.isFrozen(result)); + assert.throws(() => { (result as { status: string }).status = "expired"; }); + assert(!JSON.stringify(result).includes(minted.token)); + assert(!("tokenDigest" in result) && !("token" in result)); + registry.beginCutoff(3); + assertUnchanged(registry, () => { result = registry.peekReadAdmission(admission(minted.token, 3)); }); + assert.equal(result?.phase, "cutoff"); + for (const [input, code] of [ + [{}, "invalid_input"], + [admission(minted.token, 2), "tick_regression"], + [admission(minted.token, 3, { principal: "agent-2" }), "token_invalid"], + [admission(minted.token, 3, { runId: "run-2" }), "token_invalid"], + [admission(minted.token, 3, { worldInstanceId: "world-2" }), "token_invalid"], + [admission(Buffer.from(bytes(9)).toString("base64url"), 3), "token_invalid"], + [admission(minted.token, 1), "tick_regression"], + [admission(minted.token, 5), "token_expired"], + ] as const) assertUnchanged(registry, () => registry.peekReadAdmission(input), code); + + const expired = factory(3); + const expiredMint = expired.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + fails(() => expired.admitRead(admission(expiredMint.token, 1)), "token_expired"); + assertUnchanged(expired, () => expired.peekReadAdmission(admission(expiredMint.token, 1)), "token_expired"); + const consumed = factory(4); + const consumedMint = consumed.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 1 }); + consumed.consumeForAct(admission(consumedMint.token, 0)); + assertUnchanged(consumed, () => consumed.peekReadAdmission(admission(consumedMint.token, 0)), "token_consumed"); + const closed = factory(5); + const closedMint = closed.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + closed.beginCutoff(1); closed.closeAdmissions(1); + assertUnchanged(closed, () => closed.peekReadAdmission(admission(closedMint.token, 1)), "admissions_closed"); + closed.finalize(1); + assertUnchanged(closed, () => closed.peekReadAdmission(admission(closedMint.token, 1)), "admissions_closed"); +}); diff --git a/src/world/decisionRegistry.ts b/src/world/decisionRegistry.ts new file mode 100644 index 0000000..6fb7946 --- /dev/null +++ b/src/world/decisionRegistry.ts @@ -0,0 +1,394 @@ +import { createHash, createHmac, randomBytes as cryptoRandomBytes } from "node:crypto"; +import { + createDecisionRegistryInspection, + createDecisionRegistrySnapshot, + parseDecisionRegistrySnapshot, + formatDecisionRegistryId, + isDecisionRegistryTick, + parseDecisionRegistryTestingOptions, +} from "./decisionRegistrySnapshot.js"; +import { copySafeUint8Array } from "./decisionRegistrySnapshot.js"; +import { parseDecisionAdmissionRequest, parseDecisionMintRequest, parseDecisionRegistryConfig } from "./decisionRegistryInput.js"; +import type { DecisionRegistrySnapshot, ParsedDecisionRegistrySnapshot } from "./decisionRegistrySnapshot.js"; +import { registerConsumedDecisionResultVerifier } from "./decisionResultReadAdmission.js"; + +const TOKEN_BYTES = 32; +const MAX_ENTROPY_DRAWS = 16; +const TOKEN_DOMAIN = "simfile.decision-token.v1\0"; +const MAX_DECISION_SEQUENCE = 999_999_999_999; +const ERROR_MESSAGES = { + invalid_config: "Invalid decision registry configuration.", invalid_input: "Invalid decision registry input.", + tick_regression: "Decision registry tick regressed.", mint_closed: "Decision minting is closed.", + active_decision_exists: "An active decision already exists for this principal.", entropy_failure: "Decision token entropy failed.", + token_invalid: "Decision token admission failed.", token_expired: "Decision token has expired.", + token_consumed: "Decision token has already been consumed.", admissions_closed: "Decision admissions are closed.", + invalid_transition: "Invalid decision registry phase transition.", active_decisions_remain: "Active decisions remain.", + invalid_snapshot: "Invalid decision registry snapshot.", restore_not_pristine: "Decision registry restore requires a pristine registry.", + sequence_exhausted: "Decision registry sequence is exhausted.", +} as const; +export type DecisionRegistryErrorCode = keyof typeof ERROR_MESSAGES; +export type DecisionPhase = "open" | "cutoff" | "admissions_closed" | "finalized"; +export type DecisionStatus = "active" | "consumed" | "expired"; +const issuedDecisionRegistryErrors = new WeakSet(); +export class DecisionRegistryError extends Error { + public readonly code: DecisionRegistryErrorCode; + public constructor(code: DecisionRegistryErrorCode) { + super(ERROR_MESSAGES[code]); + this.name = "DecisionRegistryError"; + this.code = code; + issuedDecisionRegistryErrors.add(this); + } +} +/** WeakSet authentication rejects caller objects that spoof `instanceof` or the code shape. */ +export const readDecisionRegistryErrorCode = (value: unknown): DecisionRegistryErrorCode | undefined => { + const descriptor = value !== null && typeof value === "object" && issuedDecisionRegistryErrors.has(value) ? Object.getOwnPropertyDescriptor(value, "code") : undefined; + return descriptor !== undefined && "value" in descriptor && typeof descriptor.value === "string" && Object.hasOwn(ERROR_MESSAGES, descriptor.value) ? descriptor.value as DecisionRegistryErrorCode : undefined; +}; +export interface DecisionRegistryConfig { readonly runId: string; readonly worldInstanceId: string; readonly tokenDigestKey: Uint8Array; } +interface DecisionTiming { readonly issuedTick: number; readonly validThroughTick: number; } +interface DecisionBinding { readonly runId: string; readonly worldInstanceId: string; } +export interface DecisionMintRequest extends DecisionTiming { readonly principal: string; } +export interface DecisionAdmissionRequest extends DecisionBinding { readonly principal: string; readonly token: string; readonly atTick: number; } +export interface DecisionMintResult extends DecisionTiming { readonly decisionId: string; readonly token: string; } +export interface DecisionAdmission extends DecisionBinding, DecisionTiming { + readonly decisionId: string; + readonly principal: string; + readonly status: DecisionStatus; + readonly tokenDigest: string; +} +export interface DecisionReadAdmission extends DecisionTiming { readonly decisionId: string; readonly status: "active"; readonly phase: "open" | "cutoff"; } +interface DecisionResultReadVerification extends DecisionBinding, DecisionTiming { readonly decisionId: string; readonly principal: string; readonly status: "consumed"; readonly atTick: number; } +export interface DecisionRegistryInspection { + readonly phase: DecisionPhase; + readonly cutoffTick: number | null; + readonly admissionsClosedTick: number | null; + readonly finalizedTick: number | null; + readonly lastTick: number | null; + readonly nextDecisionSequence: number; + readonly decisions: readonly DecisionAdmission[]; +} +export interface DecisionRegistry { + mint(input: unknown): DecisionMintResult; + peekReadAdmission(input: unknown): DecisionReadAdmission; + admitRead(input: unknown): DecisionAdmission; + /** B22 calls this only after bearer, grant, and schema checks have succeeded. */ + consumeForAct(input: unknown): DecisionAdmission; + beginCutoff(atTick: unknown): void; + closeAdmissions(atTick: unknown): void; + finalize(atTick: unknown): void; + inspect(): DecisionRegistryInspection; + snapshot(): DecisionRegistrySnapshot; + restore(input: unknown): void; +} +export interface DecisionActReservation { readonly decisionId: string; abort(): void; commit(): void; } +const issuedDecisionRegistries = new WeakSet(); +const issuedActReservations = new WeakSet(); +const reservationIssuers = new WeakMap DecisionActReservation>(); +export const readDecisionRegistry = (value: unknown): DecisionRegistry | undefined => + value !== null && typeof value === "object" && issuedDecisionRegistries.has(value) ? value as DecisionRegistry : undefined; +export const reserveDecisionForAct = (registry: unknown, input: unknown): DecisionActReservation => { + const reserve = registry !== null && typeof registry === "object" ? reservationIssuers.get(registry) : undefined; + if (reserve === undefined) throw error("invalid_input"); + return reserve(input, false); +}; +export const readDecisionActReservation = (value: unknown): DecisionActReservation | undefined => + value !== null && typeof value === "object" && issuedActReservations.has(value) ? value as DecisionActReservation : undefined; +interface DecisionRecord extends DecisionAdmission { status: DecisionStatus; } +interface LiveState { readonly records: Map; readonly activeByPrincipal: Map; readonly phase: DecisionPhase; readonly cutoffTick: number | null; readonly admissionsClosedTick: number | null; readonly finalizedTick: number | null; readonly lastTick: number | null; readonly nextDecisionSequence: number; readonly restoreAllowed: boolean; } +type EntropySource = (size: number) => Uint8Array; +export const createDecisionRegistry = (config: unknown): DecisionRegistry => createRegistry(config, cryptoRandomBytes, MAX_DECISION_SEQUENCE); +export const createDecisionRegistryForTesting = ( + config: unknown, + options: unknown, +): DecisionRegistry => { + const parsed = parseDecisionRegistryTestingOptions(options, MAX_DECISION_SEQUENCE); + if (parsed === undefined) throw error("invalid_config"); + return createRegistry(config, parsed.randomBytes, parsed.maxDecisionSequence ?? MAX_DECISION_SEQUENCE); +}; +const createRegistry = (configInput: unknown, entropy: EntropySource, maxDecisionSequence: number): DecisionRegistry => { + const config = parseDecisionRegistryConfig(configInput); + if (config === undefined) throw error("invalid_config"); + const fingerprint = fingerprintKey(config.tokenDigestKey); + let live = pristineState(); + let operating = false; + let reentered = false; + let reservationActive = false; + const operate = (fallback: DecisionRegistryErrorCode, action: () => T): T => { + if (operating || reservationActive) { + reentered = true; + throw error("invalid_input"); + } + operating = true; + reentered = false; + try { + return action(); + } catch (cause) { + if (cause instanceof DecisionRegistryError) throw cause; + throw error(fallback); + } finally { + operating = false; + reentered = false; + } + }; + const assertNotReentered = (): void => { + if (reentered) throw error("entropy_failure"); + }; + const checkTick = (input: unknown): number => { + if (!isDecisionRegistryTick(input)) throw error("invalid_input"); + if (live.lastTick !== null && input < live.lastTick) throw error("tick_regression"); + return input; + }; + const commitTick = (atTick: number): void => { + live = { ...live, lastTick: atTick }; + for (const record of live.records.values()) { + if (record.status !== "active" || atTick <= record.validThroughTick) continue; + record.status = "expired"; + if (live.activeByPrincipal.get(record.principal) === record.tokenDigest) { + live.activeByPrincipal.delete(record.principal); + } + } + }; + /* Build the complete post-consumption graph before the transaction boundary. + * The successful reservation commit is deliberately only a pointer swap. */ + const consumedState = (record: DecisionRecord, atTick: number): LiveState => { + const records = new Map(); + const activeByPrincipal = new Map(live.activeByPrincipal); + for (const [digest, current] of live.records) { + let next: DecisionRecord = current; + if (current === record) next = { ...current, status: "consumed" }; + else if (current.status === "active" && atTick > current.validThroughTick) { + next = { ...current, status: "expired" }; + if (activeByPrincipal.get(current.principal) === digest) activeByPrincipal.delete(current.principal); + } + records.set(digest, next); + } + if (activeByPrincipal.get(record.principal) === record.tokenDigest) activeByPrincipal.delete(record.principal); + return { records, activeByPrincipal, phase: live.phase, cutoffTick: live.cutoffTick, + admissionsClosedTick: live.admissionsClosedTick, finalizedTick: live.finalizedTick, + lastTick: atTick, nextDecisionSequence: live.nextDecisionSequence, restoreAllowed: false }; + }; + const verifyAdmission = (input: unknown, commitExpired: boolean): { record: DecisionRecord; atTick: number } => { + const request = parseDecisionAdmissionRequest(input); + if (request === undefined) throw error("invalid_input"); + const atTick = checkTick(request.atTick); + if (live.phase === "admissions_closed" || live.phase === "finalized") { + throw error("admissions_closed"); + } + const record = live.records.get(digestToken(config, request.token)); + if (record === undefined || record.principal !== request.principal || request.runId !== config.runId || + request.worldInstanceId !== config.worldInstanceId || atTick < record.issuedTick) { + throw error("token_invalid"); + } + if (record.status === "expired") throw error("token_expired"); + if (record.status === "consumed") throw error("token_consumed"); + if (atTick > record.validThroughTick) { + if (commitExpired) commitTick(atTick); + throw error("token_expired"); + } + return { record, atTick }; + }; + const transition = ( + input: unknown, + expected: DecisionPhase, + next: Exclude, + frontier: "cutoffTick" | "admissionsClosedTick" | "finalizedTick", + beforeCommit?: (atTick: number) => void, + ): void => { + const atTick = checkTick(input); + if (live.phase !== expected) throw error("invalid_transition"); + beforeCommit?.(atTick); + live = { ...live, phase: next, [frontier]: atTick }; + commitTick(atTick); + live = { ...live, restoreAllowed: false }; + }; + const reserveForAct = (input: unknown, commitExpired = false): DecisionActReservation => operate("invalid_input", () => { + const { record, atTick } = verifyAdmission(input, commitExpired); + const committed = consumedState(record, atTick); + reservationActive = true; + type ReservationOperation = () => void; + const staleUse: ReservationOperation = () => { throw error("token_invalid"); }; + let commitOperation: ReservationOperation; + let abortOperation: ReservationOperation; + const successfulCommit: ReservationOperation = () => { + live = committed; + reservationActive = false; + commitOperation = staleUse; + abortOperation = staleUse; + }; + const exactAbort: ReservationOperation = () => { + reservationActive = false; + commitOperation = staleUse; + abortOperation = staleUse; + }; + commitOperation = successfulCommit; + abortOperation = exactAbort; + const reservation: DecisionActReservation = Object.freeze({ + decisionId: record.decisionId, + abort: () => abortOperation(), + commit: () => commitOperation(), + }); + issuedActReservations.add(reservation); + return reservation; + }); + const verifyConsumedResultRead = (input: unknown): DecisionResultReadVerification => operate("invalid_input", () => { + const request = parseDecisionAdmissionRequest(input); if (request === undefined) throw error("invalid_input"); + const atTick = checkTick(request.atTick); if (live.phase === "admissions_closed" || live.phase === "finalized") throw error("admissions_closed"); + const record = live.records.get(digestToken(config, request.token)); + if (record === undefined || record.status !== "consumed" || record.principal !== request.principal || request.runId !== config.runId || request.worldInstanceId !== config.worldInstanceId) throw error("token_invalid"); + if (atTick < record.issuedTick) throw error("token_invalid"); if (atTick > record.validThroughTick) throw error("token_expired"); + return freeze({ decisionId: record.decisionId, principal: record.principal, runId: record.runId, worldInstanceId: record.worldInstanceId, issuedTick: record.issuedTick, validThroughTick: record.validThroughTick, status: "consumed", atTick }); + }); + const registry: DecisionRegistry = Object.freeze({ + mint: (input: unknown) => operate("entropy_failure", () => { + const request = parseDecisionMintRequest(input); + if (request === undefined) throw error("invalid_input"); + const issuedTick = checkTick(request.issuedTick); + if (live.phase !== "open") throw error("mint_closed"); + const activeDigest = live.activeByPrincipal.get(request.principal); + if (activeDigest !== undefined && issuedTick <= live.records.get(activeDigest)!.validThroughTick) { + throw error("active_decision_exists"); + } + if (live.nextDecisionSequence > maxDecisionSequence) throw error("sequence_exhausted"); + const minted = mintToken(config, entropy, live.records, assertNotReentered); + const decisionId = formatDecisionRegistryId(live.nextDecisionSequence); + const record: DecisionRecord = { + decisionId, + principal: request.principal, + runId: config.runId, + worldInstanceId: config.worldInstanceId, + status: "active", + issuedTick, + validThroughTick: request.validThroughTick, + tokenDigest: minted.digest, + }; + commitTick(issuedTick); + live.records.set(minted.digest, record); + live.activeByPrincipal.set(request.principal, minted.digest); + live = { + ...live, + nextDecisionSequence: live.nextDecisionSequence + 1, + restoreAllowed: false, + }; + return freeze({ decisionId, token: minted.token, issuedTick, validThroughTick: request.validThroughTick }); + }), + peekReadAdmission: (input: unknown) => operate("invalid_input", () => { + const { record } = verifyAdmission(input, false); + return readAdmission(record, live.phase as "open" | "cutoff"); + }), + admitRead: (input: unknown) => operate("invalid_input", () => { + const { record, atTick } = verifyAdmission(input, true); + commitTick(atTick); + return publicRecord(record); + }), + consumeForAct: (input: unknown) => { + const reservation = reserveForAct(input, true); + const request = parseDecisionAdmissionRequest(input); + const record = request === undefined ? undefined : live.records.get(digestToken(config, request.token)); + if (record === undefined) { reservation.abort(); throw error("token_invalid"); } + const result = publicRecord({ ...record, status: "consumed" }); + reservation.commit(); + return result; + }, + beginCutoff: (input: unknown) => operate("invalid_input", () => + transition(input, "open", "cutoff", "cutoffTick")), + closeAdmissions: (input: unknown) => operate("invalid_input", () => + transition(input, "cutoff", "admissions_closed", "admissionsClosedTick", (atTick) => { + if (hasActiveDecisionAt(live.records, atTick)) throw error("active_decisions_remain"); + })), + finalize: (input: unknown) => operate("invalid_input", () => + transition(input, "admissions_closed", "finalized", "finalizedTick")), + inspect: () => operate("invalid_input", () => createDecisionRegistryInspection(live, [...live.records.values()].sort(byId))), + snapshot: () => operate("invalid_snapshot", () => createDecisionRegistrySnapshot( + config.runId, config.worldInstanceId, fingerprint, live, [...live.records.values()].sort(byId), + )), + restore: (input: unknown) => operate("invalid_snapshot", () => { + if (!live.restoreAllowed) throw error("restore_not_pristine"); + const parsed = parseDecisionRegistrySnapshot(input, { + runId: config.runId, + worldInstanceId: config.worldInstanceId, + tokenDigestKeyFingerprint: fingerprint, + }); + if (parsed === undefined) throw error("invalid_snapshot"); + live = restoredState(parsed, config); + }), + }); + issuedDecisionRegistries.add(registry); + reservationIssuers.set(registry, reserveForAct); + registerConsumedDecisionResultVerifier(registry, verifyConsumedResultRead); + return registry; +}; +const mintToken = ( + config: DecisionRegistryConfig, + entropy: EntropySource, + records: ReadonlyMap, + assertNotReentered: () => void, +): { token: string; digest: string } => { + for (let draw = 0; draw < MAX_ENTROPY_DRAWS; draw += 1) { + let candidate: unknown; + try { + candidate = entropy(TOKEN_BYTES); + } catch { + throw error("entropy_failure"); + } + assertNotReentered(); + const bytes = copySafeUint8Array(candidate); + if (bytes === undefined || bytes.byteLength !== TOKEN_BYTES) throw error("entropy_failure"); + const token = Buffer.from(bytes).toString("base64url"); + const digest = digestToken(config, token); + if (!records.has(digest)) return { token, digest }; + } + throw error("entropy_failure"); +}; +const pristineState = (): LiveState => ({ + records: new Map(), + activeByPrincipal: new Map(), + phase: "open", + cutoffTick: null, + admissionsClosedTick: null, + finalizedTick: null, + lastTick: null, + nextDecisionSequence: 1, + restoreAllowed: true, +}); +const restoredState = ( + snapshot: ParsedDecisionRegistrySnapshot, + config: DecisionRegistryConfig, +): LiveState => { + const records = new Map(); + const activeByPrincipal = new Map(); + for (const decision of snapshot.decisions) { + const record: DecisionRecord = { + ...decision, + runId: config.runId, + worldInstanceId: config.worldInstanceId, + }; + records.set(record.tokenDigest, record); + if (record.status === "active") activeByPrincipal.set(record.principal, record.tokenDigest); + } + return { + records, + activeByPrincipal, + phase: snapshot.phase, + cutoffTick: snapshot.cutoffTick, + admissionsClosedTick: snapshot.admissionsClosedTick, + finalizedTick: snapshot.finalizedTick, + lastTick: snapshot.lastTick, + nextDecisionSequence: snapshot.nextDecisionSequence, + restoreAllowed: false, + }; +}; +const hasActiveDecisionAt = (records: ReadonlyMap, atTick: number): boolean => + [...records.values()].some((record) => record.status === "active" && atTick <= record.validThroughTick); +const digestToken = (config: DecisionRegistryConfig, token: string): string => + `sha256:${createHmac("sha256", config.tokenDigestKey) + .update(TOKEN_DOMAIN).update(config.runId).update("\0").update(token).digest("hex")}`; +const fingerprintKey = (key: Uint8Array): string => + `sha256:${createHash("sha256").update(key).digest("hex")}`; +const publicRecord = (record: DecisionRecord): DecisionAdmission => freeze({ ...record }); +const readAdmission = (record: DecisionRecord, phase: "open" | "cutoff"): DecisionReadAdmission => freeze({ + decisionId: record.decisionId, status: "active", issuedTick: record.issuedTick, validThroughTick: record.validThroughTick, phase, +}); +const byId = (left: DecisionRecord, right: DecisionRecord): number => + left.decisionId === right.decisionId ? 0 : left.decisionId < right.decisionId ? -1 : 1; +const error = (code: DecisionRegistryErrorCode): DecisionRegistryError => new DecisionRegistryError(code); +const freeze = (value: T): T => Object.freeze(value); diff --git a/src/world/decisionRegistryInput.ts b/src/world/decisionRegistryInput.ts new file mode 100644 index 0000000..91a7587 --- /dev/null +++ b/src/world/decisionRegistryInput.ts @@ -0,0 +1,21 @@ +import { Buffer } from "node:buffer"; +import { copySafeUint8Array, isDecisionRegistryBinding, isDecisionRegistryTick, readDataObject } from "./decisionRegistrySnapshot.js"; +import type { DecisionAdmissionRequest, DecisionMintRequest, DecisionRegistryConfig } from "./decisionRegistry.js"; + +const TOKEN_BYTES = 32; +export const parseDecisionRegistryConfig = (input: unknown): DecisionRegistryConfig | undefined => { + const value = readDataObject(input, ["runId", "worldInstanceId", "tokenDigestKey"]); const key = value?.tokenDigestKey; const copy = copySafeUint8Array(key); + return value === undefined || !isDecisionRegistryBinding(value.runId) || !isDecisionRegistryBinding(value.worldInstanceId) || copy === undefined || copy.byteLength < TOKEN_BYTES + ? undefined : { runId: value.runId, worldInstanceId: value.worldInstanceId, tokenDigestKey: copy }; +}; +export const parseDecisionMintRequest = (input: unknown): DecisionMintRequest | undefined => { + const value = readDataObject(input, ["principal", "issuedTick", "validThroughTick"]); + return value === undefined || !isDecisionRegistryBinding(value.principal) || !isDecisionRegistryTick(value.issuedTick) || !isDecisionRegistryTick(value.validThroughTick) || value.validThroughTick < value.issuedTick + ? undefined : { principal: value.principal, issuedTick: value.issuedTick, validThroughTick: value.validThroughTick }; +}; +export const parseDecisionAdmissionRequest = (input: unknown): DecisionAdmissionRequest | undefined => { + const value = readDataObject(input, ["principal", "runId", "worldInstanceId", "token", "atTick"]); + return value === undefined || !isDecisionRegistryBinding(value.principal) || !isDecisionRegistryBinding(value.runId) || !isDecisionRegistryBinding(value.worldInstanceId) || !isCanonicalDecisionToken(value.token) || !isDecisionRegistryTick(value.atTick) + ? undefined : { principal: value.principal, runId: value.runId, worldInstanceId: value.worldInstanceId, token: value.token, atTick: value.atTick }; +}; +export const isCanonicalDecisionToken = (input: unknown): input is string => typeof input === "string" && /^[A-Za-z0-9_-]{43}$/.test(input) && Buffer.from(input, "base64url").byteLength === TOKEN_BYTES && Buffer.from(input, "base64url").toString("base64url") === input; diff --git a/src/world/decisionRegistryReachability.test.ts b/src/world/decisionRegistryReachability.test.ts new file mode 100644 index 0000000..3a8a289 --- /dev/null +++ b/src/world/decisionRegistryReachability.test.ts @@ -0,0 +1,125 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { DecisionRegistryError } from "./index.js"; +import { createDecisionRegistryForTesting } from "./decisionRegistry.js"; +import type { DecisionPhase, DecisionRegistrySnapshot, DecisionStatus } from "./index.js"; + +const key = new Uint8Array(32).fill(7); +const config = { runId: "run-1", worldInstanceId: "world-1", tokenDigestKey: key }; +const registry = () => createDecisionRegistryForTesting(config, { + randomBytes: () => new Uint8Array(32).fill(9) +}); +const copy = (value: T): T => JSON.parse(JSON.stringify(value)) as T; + +interface RecordSpec { + readonly principal: string; + readonly status: DecisionStatus; + readonly issuedTick: number; + readonly validThroughTick: number; +} + +const candidate = ( + records: readonly RecordSpec[], + lastTick: number, + phase: DecisionPhase = "open", +): DecisionRegistrySnapshot => { + const snapshot = copy(registry().snapshot()) as unknown as { + phase: DecisionPhase; + cutoffTick: number | null; + admissionsClosedTick: number | null; + finalizedTick: number | null; + lastTick: number; + nextDecisionSequence: number; + decisions: Record[]; + }; + snapshot.phase = phase; + snapshot.cutoffTick = phase === "open" ? null : 1; + snapshot.admissionsClosedTick = phase === "admissions_closed" || phase === "finalized" ? 2 : null; + snapshot.finalizedTick = phase === "finalized" ? 3 : null; + snapshot.lastTick = lastTick; + snapshot.nextDecisionSequence = records.length + 1; + snapshot.decisions = records.map((record, index) => ({ + decisionId: `decision-${String(index + 1).padStart(12, "0")}`, + tokenDigest: `sha256:${String(index + 1).repeat(64)}`, + ...record, + })); + return snapshot as unknown as DecisionRegistrySnapshot; +}; + +const rejects = (snapshot: DecisionRegistrySnapshot): void => { + const target = registry(); + const before = target.snapshot(); + assert.throws( + () => target.restore(snapshot), + (error: unknown) => error instanceof DecisionRegistryError && error.code === "invalid_snapshot", + ); + assert.deepEqual(target.snapshot(), before); + assert(Object.isFrozen(target.snapshot())); + assert.equal(target.mint({ principal: "probe", issuedTick: 0, validThroughTick: 0 }).decisionId, "decision-000000000001"); +}; + +test("rejects histories whose terminal records cannot produce the final clock event", () => { + const sweptThenConsumed = [ + { principal: "a", status: "expired", issuedTick: 0, validThroughTick: 0 }, + { principal: "b", status: "consumed", issuedTick: 1, validThroughTick: 1 }, + ] as const; + const consumedBeforeSuccessor = [ + { principal: "a", status: "consumed", issuedTick: 0, validThroughTick: 5 }, + { principal: "a", status: "consumed", issuedTick: 1, validThroughTick: 1 }, + ] as const; + for (const phase of ["open", "cutoff"] as const) { + rejects(candidate(sweptThenConsumed, 2, phase)); + rejects(candidate(consumedBeforeSuccessor, 2, phase)); + } +}); + +test("rejects unreachable frontier clocks and expired-at-admission histories", () => { + rejects(candidate([], 2, "cutoff")); + rejects(candidate([ + { principal: "a", status: "consumed", issuedTick: 0, validThroughTick: 1 } + ], 2)); + rejects(candidate([ + { principal: "a", status: "expired", issuedTick: 0, validThroughTick: 2 } + ], 2, "admissions_closed")); + rejects(candidate([ + { principal: "a", status: "consumed", issuedTick: 0, validThroughTick: 3 } + ], 3, "admissions_closed")); + rejects(candidate([ + { principal: "a", status: "consumed", issuedTick: 0, validThroughTick: 3 } + ], 4, "finalized")); +}); + +test("accepts terminal consume, read, first-expiry, cutoff, admission, and final events", () => { + const cases = [ + candidate([{ principal: "a", status: "active", issuedTick: 0, validThroughTick: 2 }], 2), + candidate([{ principal: "a", status: "consumed", issuedTick: 0, validThroughTick: 2 }], 2), + candidate([{ principal: "a", status: "expired", issuedTick: 0, validThroughTick: 1 }], 2), + candidate([], 1, "cutoff"), + candidate([{ principal: "a", status: "consumed", issuedTick: 0, validThroughTick: 2 }], 2, "admissions_closed"), + candidate([{ principal: "a", status: "consumed", issuedTick: 0, validThroughTick: 3 }], 3, "finalized"), + ]; + for (const snapshot of cases) { + const target = registry(); + target.restore(snapshot); + assert.deepEqual(target.snapshot(), snapshot); + } +}); + +test("enforces strict expired-principal reuse while preserving legal terminal successors", () => { + for (const status of ["active", "consumed", "expired"] as const) { + rejects(candidate([ + { principal: "a", status: "expired", issuedTick: 0, validThroughTick: 1 }, + { principal: "a", status, issuedTick: 1, validThroughTick: status === "active" ? 2 : 1 }, + ], 2)); + } + for (const status of ["active", "consumed", "expired"] as const) { + const snapshot = candidate([ + { principal: "a", status: "expired", issuedTick: 0, validThroughTick: 1 }, + { principal: "a", status, issuedTick: 2, validThroughTick: 2 }, + ], status === "expired" ? 3 : 2); + const target = registry(); + target.restore(snapshot); + assert.equal(target.inspect().decisions[1]?.status, status); + } +}); diff --git a/src/world/decisionRegistryReservation.test.ts b/src/world/decisionRegistryReservation.test.ts new file mode 100644 index 0000000..b13b8dd --- /dev/null +++ b/src/world/decisionRegistryReservation.test.ts @@ -0,0 +1,182 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + DecisionRegistryError, + createDecisionRegistryForTesting, + readDecisionActReservation, + readDecisionRegistryErrorCode, + reserveDecisionForAct, +} from "./decisionRegistry.js"; + +const config = () => ({ + runId: "run", + worldInstanceId: "world", + tokenDigestKey: new Uint8Array(32).fill(1), +}); +const registry = (...bytes: number[]) => { + let index = 0; + return createDecisionRegistryForTesting(config(), { + randomBytes: () => new Uint8Array(32).fill(bytes[index++] ?? 99), + }); +}; +const admission = (token: string, atTick = 0, overrides = {}) => ({ + principal: "p", runId: "run", worldInstanceId: "world", token, atTick, ...overrides, +}); +const fails = (operation: () => unknown, code: string) => assert.throws( + operation, + (value: unknown) => value instanceof DecisionRegistryError && value.code === code, +); +const mint = (value: ReturnType, principal = "p", validThroughTick = 4) => + value.mint({ principal, issuedTick: 0, validThroughTick }); + +test("abort preserves the exact open snapshot and releases the exclusive lock", () => { + const value = registry(1); + const minted = mint(value); + const before = value.snapshot(); + const reservation = reserveDecisionForAct(value, admission(minted.token)); + fails(() => value.mint({ principal: "other", issuedTick: 0, validThroughTick: 0 }), "invalid_input"); + fails(() => value.peekReadAdmission(admission(minted.token)), "invalid_input"); + reservation.abort(); + assert.deepEqual(value.snapshot(), before); + assert.equal(value.inspect().decisions[0]!.status, "active"); + assert.equal(value.consumeForAct(admission(minted.token)).status, "consumed"); +}); + +test("abort preserves the exact cutoff snapshot and cutoff remains admissible", () => { + const value = registry(1); + const minted = mint(value, "p", 2); + value.beginCutoff(1); + const before = value.snapshot(); + const reservation = reserveDecisionForAct(value, admission(minted.token, 1)); + reservation.abort(); + assert.deepEqual(value.snapshot(), before); + assert.equal(value.inspect().phase, "cutoff"); + assert.equal(value.consumeForAct(admission(minted.token, 1)).status, "consumed"); +}); + +test("commit installs the consumed result and sweeps unrelated expired decisions", () => { + const value = registry(1, 2); + const consumed = mint(value, "p", 3); + mint(value, "other", 1); + const reservation = reserveDecisionForAct(value, admission(consumed.token, 2)); + reservation.commit(); + assert.deepEqual(value.inspect().decisions.map(({ status }) => status), ["consumed", "expired"]); + assert.equal(value.inspect().lastTick, 2); +}); + +test("every registry operation is locked until abort or commit", () => { + const value = registry(1); + const minted = mint(value); + const reservation = reserveDecisionForAct(value, admission(minted.token)); + const operations: readonly (() => unknown)[] = [ + () => value.mint({ principal: "other", issuedTick: 0, validThroughTick: 0 }), + () => value.peekReadAdmission(admission(minted.token)), + () => value.admitRead(admission(minted.token)), + () => value.consumeForAct(admission(minted.token)), + () => value.beginCutoff(0), + () => value.closeAdmissions(0), + () => value.finalize(0), + () => value.inspect(), + () => value.snapshot(), + () => value.restore({}), + ]; + for (const operation of operations) fails(operation, "invalid_input"); + reservation.commit(); + assert.equal(value.inspect().decisions[0]!.status, "consumed"); +}); + +test("rejects wrong bindings, tokens, ticks, and lifecycle admissions", () => { + const value = registry(1, 2, 3, 4, 5, 6); + const minted = mint(value, "p", 4); + const cases: readonly [unknown, string][] = [ + [admission(minted.token, 0, { principal: "other" }), "token_invalid"], + [admission(minted.token, 0, { runId: "other" }), "token_invalid"], + [admission(minted.token, 0, { worldInstanceId: "other" }), "token_invalid"], + [admission(Buffer.from(new Uint8Array(32).fill(55)).toString("base64url")), "token_invalid"], + [admission("not-a-token"), "invalid_input"], + [admission(minted.token, -1), "invalid_input"], + [admission(minted.token, 5), "token_expired"], + ]; + for (const [input, code] of cases) fails(() => reserveDecisionForAct(value, input), code); + + const consumed = registry(7); + const consumedMint = mint(consumed); + consumed.consumeForAct(admission(consumedMint.token)); + fails(() => reserveDecisionForAct(consumed, admission(consumedMint.token)), "token_consumed"); + + const closed = registry(8); + const closedMint = mint(closed, "p", 0); + closed.beginCutoff(1); + closed.closeAdmissions(1); + fails(() => reserveDecisionForAct(closed, admission(closedMint.token, 1)), "admissions_closed"); + closed.finalize(2); + fails(() => reserveDecisionForAct(closed, admission(closedMint.token, 2)), "admissions_closed"); +}); + +test("expired reservations retain state while legacy consumeForAct performs its sweep", () => { + const value = registry(1, 2); + const expired = mint(value, "p", 0); + mint(value, "other", 0); + const before = value.snapshot(); + fails(() => reserveDecisionForAct(value, admission(expired.token, 1)), "token_expired"); + assert.deepEqual(value.snapshot(), before); + fails(() => value.consumeForAct(admission(expired.token, 1)), "token_expired"); + assert.deepEqual(value.inspect().decisions.map(({ status }) => status), ["expired", "expired"]); +}); + +test("all first-operation combinations become stale and fail closed", () => { + for (const first of ["commit", "abort"] as const) { + for (const second of ["commit", "abort"] as const) { + const value = registry(1); + const minted = mint(value); + const reservation = reserveDecisionForAct(value, admission(minted.token)); + reservation[first](); + fails(() => reservation[second](), "token_invalid"); + fails(() => reservation.commit(), "token_invalid"); + fails(() => reservation.abort(), "token_invalid"); + } + } +}); + +test("reservation issuance and use reject structural lookalikes", () => { + const value = registry(1); + const minted = mint(value); + const real = reserveDecisionForAct(value, admission(minted.token)); + assert.equal(readDecisionActReservation(real), real); + const lookalike = { decisionId: real.decisionId, commit: real.commit, abort: real.abort }; + assert.equal(readDecisionActReservation(lookalike), undefined); + assert.equal(readDecisionActReservation({ decisionId: real.decisionId, commit() {}, abort() {} }), undefined); + fails(() => reserveDecisionForAct({ ...value }, admission(minted.token)), "invalid_input"); + real.abort(); +}); + +test("decision registry error codes reject structural and prototype lookalikes", () => { + const real = new DecisionRegistryError("token_invalid"); + assert.equal(readDecisionRegistryErrorCode(real), "token_invalid"); + assert.equal(readDecisionRegistryErrorCode({ code: "token_invalid" }), undefined); + const prototypeLookalike = Object.assign( + Object.create(DecisionRegistryError.prototype) as object, + { code: "token_invalid" }, + ); + assert.equal(prototypeLookalike instanceof DecisionRegistryError, true); + assert.equal(readDecisionRegistryErrorCode(prototypeLookalike), undefined); +}); + +test("legacy consumeForAct preserves status and expired sweep compatibility", () => { + const value = registry(1, 2); + const first = mint(value, "p", 2); + mint(value, "other", 0); + const result = value.consumeForAct(admission(first.token, 1)); + assert.deepEqual(result, { + decisionId: first.decisionId, + principal: "p", + runId: "run", + worldInstanceId: "world", + status: "consumed", + issuedTick: 0, + validThroughTick: 2, + tokenDigest: value.inspect().decisions[0]!.tokenDigest, + }); + assert.equal(value.inspect().decisions[1]!.status, "expired"); + fails(() => value.consumeForAct(admission(first.token, 1)), "token_consumed"); +}); diff --git a/src/world/decisionRegistryRestore.test.ts b/src/world/decisionRegistryRestore.test.ts new file mode 100644 index 0000000..5cfbabd --- /dev/null +++ b/src/world/decisionRegistryRestore.test.ts @@ -0,0 +1,327 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + DECISION_REGISTRY_SNAPSHOT_VERSION, + DecisionRegistryError, + createDecisionRegistry, +} from "./index.js"; +import type { DecisionRegistry, DecisionRegistrySnapshot } from "./index.js"; +import { createDecisionRegistryForTesting } from "./decisionRegistry.js"; + +const key = (offset = 0) => new Uint8Array(Array.from({ length: 32 }, (_, index) => index + offset)); +const config = (offset = 0) => ({ runId: "run-1", worldInstanceId: "world-1", tokenDigestKey: key(offset) }); +const bytes = (value: number) => new Uint8Array(32).fill(value); +const factory = (...values: number[]) => { + let index = 0; + return createDecisionRegistryForTesting(config(), { randomBytes: () => bytes(values[index++] ?? 255) }); +}; +const limitedFactory = (limit: number, ...values: number[]) => { + let index = 0; + return createDecisionRegistryForTesting(config(), { randomBytes: () => bytes(values[index++] ?? 255), maxDecisionSequence: limit }); +}; +const admission = (token: string, atTick: number, principal = "agent-1") => + ({ principal, runId: "run-1", worldInstanceId: "world-1", token, atTick }); +const copy = (value: T): T => JSON.parse(JSON.stringify(value)) as T; +const fails = (fn: () => unknown, code: string) => + assert.throws(fn, (value: unknown) => value instanceof DecisionRegistryError && value.code === code); +const failsRedacted = (fn: () => unknown, code: string, token: string) => + assert.throws(fn, (value: unknown) => value instanceof DecisionRegistryError && value.code === code && !String(value).includes(token)); +const restoreFailsAtomically = (registry: DecisionRegistry, candidate: unknown) => { + const before = registry.snapshot(); + fails(() => registry.restore(candidate), "invalid_snapshot"); + assert.deepEqual(registry.snapshot(), before); + assert(Object.isFrozen(registry.snapshot())); +}; + +test("exports a canonical immutable redacted snapshot", () => { + const registry = factory(1, 2); + const first = registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 2 }); + registry.consumeForAct(admission(first.token, 1)); + registry.mint({ principal: "agent-1", issuedTick: 1, validThroughTick: 3 }); + const snapshot = registry.snapshot(); + assert.equal(snapshot.version, DECISION_REGISTRY_SNAPSHOT_VERSION); + assert(Object.isFrozen(snapshot)); + assert(Object.isFrozen(snapshot.decisions)); + assert(Object.isFrozen(snapshot.decisions[0])); + assert.throws(() => { (snapshot as { phase: string }).phase = "finalized"; }); + assert.throws(() => { (snapshot.decisions as unknown as unknown[]).push({}); }); + assert.deepEqual(registry.snapshot(), snapshot); + assert(!JSON.stringify(snapshot).includes(first.token)); + assert.match(snapshot.tokenDigestKeyFingerprint, /^sha256:[0-9a-f]{64}$/); +}); + +test("round trips open, cutoff, admissions closure, and finalization", () => { + const open = factory(1); + const openToken = open.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 3 }); + const cutoff = factory(2); + const cutoffToken = cutoff.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 3 }); + cutoff.beginCutoff(1); + const closed = factory(3); + const closedToken = closed.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 1 }); + closed.beginCutoff(2); + closed.closeAdmissions(2); + const finalized = factory(4); + const finalizedToken = finalized.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + finalized.beginCutoff(1); + finalized.closeAdmissions(1); + finalized.finalize(2); + for (const [source, token, phase] of [[open, openToken.token, "open"], [cutoff, cutoffToken.token, "cutoff"], + [closed, closedToken.token, "admissions_closed"], [finalized, finalizedToken.token, "finalized"]] as const) { + const restored = factory(9); + const snapshot = source.snapshot(); + restored.restore(snapshot); + assert.deepEqual(restored.snapshot(), snapshot); + assert.equal(restored.inspect().phase, phase); + if (phase === "open") { + assert.equal(restored.admitRead(admission(token, 1)).decisionId, "decision-000000000001"); + restored.beginCutoff(1); + assert.equal(restored.inspect().phase, "cutoff"); + } else if (phase === "cutoff") { + assert.equal(restored.consumeForAct(admission(token, 1)).status, "consumed"); + restored.closeAdmissions(1); + assert.equal(restored.inspect().phase, "admissions_closed"); + } else if (phase === "admissions_closed") { + fails(() => restored.admitRead(admission(token, 2)), "admissions_closed"); + restored.finalize(2); + assert.equal(restored.inspect().phase, "finalized"); + } else fails(() => restored.admitRead(admission(token, 2)), "admissions_closed"); + } +}); + +test("restored records retain consumption, expiry, binding, and future sequence behavior", () => { + const consumed = factory(1, 2); + const first = consumed.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 2 }); + consumed.consumeForAct(admission(first.token, 1)); + const restoredConsumed = factory(3); + restoredConsumed.restore(consumed.snapshot()); + failsRedacted(() => restoredConsumed.admitRead(admission(first.token, 1)), "token_consumed", first.token); + assert(!JSON.stringify(restoredConsumed.inspect()).includes(first.token)); + assert.equal(restoredConsumed.mint({ principal: "agent-1", issuedTick: 2, validThroughTick: 2 }).decisionId, "decision-000000000002"); + + const expired = factory(4); + const expiredToken = expired.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + fails(() => expired.admitRead(admission(expiredToken.token, 1)), "token_expired"); + const restoredExpired = factory(5); + restoredExpired.restore(expired.snapshot()); + failsRedacted(() => restoredExpired.admitRead(admission(expiredToken.token, 1)), "token_expired", expiredToken.token); + assert(!JSON.stringify(restoredExpired.inspect()).includes(expiredToken.token)); + assert.equal(restoredExpired.mint({ principal: "agent-1", issuedTick: 1, validThroughTick: 1 }).decisionId, "decision-000000000002"); +}); + +test("restore is one-time, pristine-only, and rejects configuration conflicts", () => { + const source = factory(1); + source.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + const snapshot = source.snapshot(); + const restored = factory(2); + restored.restore(snapshot); + fails(() => restored.restore(snapshot), "restore_not_pristine"); + const changed = factory(3); + changed.beginCutoff(0); + fails(() => changed.restore(snapshot), "restore_not_pristine"); + for (const candidate of [{ ...copy(snapshot), version: "other" }, { ...copy(snapshot), runId: "run-2" }, + { ...copy(snapshot), worldInstanceId: "world-2" }, { ...copy(snapshot), tokenDigestKeyFingerprint: "sha256:".padEnd(71, "0") }]) { + restoreFailsAtomically(factory(4), candidate); + } + const wrongKey = createDecisionRegistryForTesting(config(1), { randomBytes: () => bytes(5) }); + restoreFailsAtomically(wrongKey, snapshot); +}); + +test("rejects hostile structure and every candidate rejection preserves a usable empty registry", () => { + const source = factory(1, 2); + source.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 2 }); + source.mint({ principal: "agent-2", issuedTick: 1, validThroughTick: 2 }); + const valid = source.snapshot(); + const accessor = copy(valid) as unknown as Record; + Object.defineProperty(accessor, "version", { enumerable: true, get: () => { throw new Error("getter invoked"); } }); + const sparse = copy(valid) as unknown as { decisions: unknown[] }; + delete sparse.decisions[0]; + const symbol = copy(valid) as unknown as Record; + symbol[Symbol("extra")] = true; + const hidden = copy(valid) as unknown as Record; + Object.defineProperty(hidden, "hidden", { enumerable: false, value: true }); + const outOfOrder = copy(valid) as unknown as { decisions: unknown[] }; + outOfOrder.decisions.reverse(); + const additional = { ...copy(valid), additional: true }; + const badArray = copy(valid) as unknown as { decisions: unknown }; + badArray.decisions = new Date(); + for (const candidate of [undefined, null, [], new Date(), accessor, sparse, symbol, hidden, outOfOrder, additional, badArray]) { + assert.doesNotThrow(() => restoreFailsAtomically(factory(7), candidate)); + } + const usable = factory(8); + restoreFailsAtomically(usable, additional); + assert.equal(usable.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }).decisionId, "decision-000000000001"); +}); + +test("rejects transparent, throwing, and reentrant proxies without invoking traps", () => { + const source = factory(1, 2); + source.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 2 }); + source.mint({ principal: "agent-2", issuedTick: 1, validThroughTick: 2 }); + const valid = source.snapshot(); + const candidates = [ + () => new Proxy(copy(valid), {}), + () => ({ ...copy(valid), decisions: new Proxy(copy(valid.decisions), {}) }), + () => ({ ...copy(valid), decisions: [new Proxy(copy(valid.decisions[0]!), {})] }), + ]; + for (const makeCandidate of candidates) restoreFailsAtomically(factory(3), makeCandidate()); + for (const layer of ["top", "array", "record"] as const) { + const target = factory(4); + let traps = 0; + const source = layer === "top" ? copy(valid) : + layer === "array" ? copy(valid.decisions) : copy(valid.decisions[0]!); + const hostile = new Proxy(source, { + getPrototypeOf: () => { + traps += 1; + target.mint({ principal: "attacker", issuedTick: 0, validThroughTick: 0 }); + throw new Error("trap"); + }, + }); + const candidate = layer === "top" ? hostile : layer === "array" ? { ...copy(valid), decisions: hostile } : + { ...copy(valid), decisions: [hostile, copy(valid.decisions[1]!)] }; + restoreFailsAtomically(target, candidate); + assert.equal(traps, 0); + assert.equal(target.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }).decisionId, "decision-000000000001"); + } +}); + +test("rejects malformed records, duplicate identity material, and lifecycle impossibilities", () => { + const source = factory(1, 2); + source.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 3 }); + source.mint({ principal: "agent-2", issuedTick: 1, validThroughTick: 3 }); + const valid = source.snapshot(); + const mutate = (change: (value: Record) => void) => { + const candidate = copy(valid) as unknown as Record; + change(candidate); + restoreFailsAtomically(factory(3), candidate); + }; + mutate((value) => { (value.decisions as Record[])[0]!.decisionId = "decision-000000000000"; }); + mutate((value) => { (value.decisions as Record[])[0]!.tokenDigest = "sha256:bad"; }); + mutate((value) => { (value.decisions as Record[])[0]!.status = "unknown"; }); + mutate((value) => { (value.decisions as Record[])[0]!.issuedTick = -1; }); + mutate((value) => { (value.decisions as Record[])[0]!.issuedTick = Number.MAX_SAFE_INTEGER + 1; }); + mutate((value) => { (value.decisions as Record[])[0]!.validThroughTick = 0; }); + mutate((value) => { (value.decisions as Record[])[0]!.status = "expired"; }); + mutate((value) => { (value.decisions as Record[])[1]!.decisionId = "decision-000000000001"; }); + mutate((value) => { (value.decisions as Record[])[1]!.tokenDigest = (value.decisions as Record[])[0]!.tokenDigest; }); + mutate((value) => { (value.decisions as Record[])[1]!.principal = "agent-1"; }); + mutate((value) => { value.nextDecisionSequence = 1; }); + mutate((value) => { value.nextDecisionSequence = -1; }); + mutate((value) => { value.lastTick = 0; }); + mutate((value) => { value.lastTick = Number.MAX_SAFE_INTEGER + 1; }); + mutate((value) => { + value.phase = "admissions_closed"; + value.cutoffTick = 1; + value.admissionsClosedTick = 1; + }); + mutate((value) => { value.phase = "cutoff"; }); + mutate((value) => { value.cutoffTick = 2; }); + mutate((value) => { + (value.decisions as Record[])[1]!.decisionId = "decision-000000000003"; + value.nextDecisionSequence = 4; + }); + mutate((value) => { + const decisions = value.decisions as Record[]; + decisions[0]!.issuedTick = 1; + decisions[1]!.issuedTick = 0; + }); + mutate((value) => { + const decisions = value.decisions as Record[]; + decisions[1]!.principal = decisions[0]!.principal; + decisions[1]!.status = "consumed"; + }); + mutate((value) => { + value.decisions = []; + value.nextDecisionSequence = 1; + value.lastTick = 0; + }); +}); + +test("permits historical principal reuse and proves contiguous restored exhaustion without oversized ids", () => { + const source = factory(1, 2); + const first = source.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 1 }); + source.consumeForAct(admission(first.token, 1)); + source.mint({ principal: "agent-1", issuedTick: 1, validThroughTick: 2 }); + const historical = factory(3); + historical.restore(source.snapshot()); + assert.equal(historical.inspect().decisions.length, 2); + const exhaustedSource = limitedFactory(2, 4, 5); + const consumed = exhaustedSource.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 1 }); + exhaustedSource.consumeForAct(admission(consumed.token, 1)); + exhaustedSource.mint({ principal: "agent-1", issuedTick: 1, validThroughTick: 1 }); + const exhausted = exhaustedSource.snapshot(); + assert.deepEqual(exhausted.decisions.map(({ decisionId }) => decisionId), ["decision-000000000001", "decision-000000000002"]); + const exhaustedRegistry = limitedFactory(2, 6); + exhaustedRegistry.restore(exhausted); + const before = exhaustedRegistry.snapshot(); + fails(() => exhaustedRegistry.mint({ principal: "agent-2", issuedTick: 1, validThroughTick: 1 }), "sequence_exhausted"); + assert.deepEqual(exhaustedRegistry.snapshot(), before); + assert.equal(exhaustedRegistry.snapshot().nextDecisionSequence, 3); +}); + +test("restoration clones caller-owned data and every hostile failure remains redacted", () => { + const source = factory(1); + const minted = source.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 1 }); + const candidate = copy(source.snapshot()) as unknown as { decisions: Record[] }; + const restored = factory(2); + restored.restore(candidate); + candidate.decisions[0]!.principal = "changed"; + assert.equal(restored.inspect().decisions[0]?.principal, "agent-1"); + const secret = minted.token; + for (const hostile of [new Proxy(copy(source.snapshot()), {}), { ...copy(source.snapshot()), decisions: new Proxy([], {}) }]) { + assert.throws(() => restored.restore(hostile), (value: unknown) => value instanceof DecisionRegistryError && !String(value).includes(secret)); + } +}); + +test("key and entropy byte validation never invokes hostile typed-array properties", () => { + const decorate = (value: T) => { + let accesses = 0; + for (const key of ["byteLength", "length", "constructor", Symbol.iterator]) { + Object.defineProperty(value, key, { + configurable: true, + get: () => { + accesses += 1; + return undefined; + }, + }); + } + return { value, accesses: () => accesses }; + }; + for (const input of [decorate(bytes(1)), decorate(Buffer.from(bytes(2)))]) { + const registry = createDecisionRegistry({ ...config(), tokenDigestKey: input.value }); + registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + assert.equal(input.accesses(), 0); + } + for (const input of [decorate(bytes(3)), decorate(Buffer.from(bytes(4)))]) { + const registry = createDecisionRegistryForTesting(config(), { randomBytes: () => input.value }); + registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }); + assert.equal(input.accesses(), 0); + } +}); + +test("key and entropy byte validation rejects altered prototypes without invoking traps", () => { + class ByteSubclass extends Uint8Array {} + const hostileValues = () => { + let traps = 0; + const custom = bytes(1); + Object.setPrototypeOf(custom, {}); + const proxyPrototype = bytes(2); + Object.setPrototypeOf(proxyPrototype, new Proxy({}, { + get: () => { + traps += 1; + return undefined; + }, + })); + return { values: [new ByteSubclass(32), custom, proxyPrototype], traps: () => traps }; + }; + const configured = hostileValues(); + for (const tokenDigestKey of configured.values) { + fails(() => createDecisionRegistry({ ...config(), tokenDigestKey }), "invalid_config"); + } + assert.equal(configured.traps(), 0); + const entropy = hostileValues(); + for (const output of entropy.values) { + const registry = createDecisionRegistryForTesting(config(), { randomBytes: () => output }); + fails(() => registry.mint({ principal: "agent-1", issuedTick: 0, validThroughTick: 0 }), "entropy_failure"); + assert.equal(registry.inspect().nextDecisionSequence, 1); + } + assert.equal(entropy.traps(), 0); +}); diff --git a/src/world/decisionRegistrySnapshot.ts b/src/world/decisionRegistrySnapshot.ts new file mode 100644 index 0000000..9dc8c8f --- /dev/null +++ b/src/world/decisionRegistrySnapshot.ts @@ -0,0 +1,382 @@ +import { types as nodeTypes } from "node:util"; +import type { + DecisionAdmission, + DecisionPhase, + DecisionRegistryInspection, + DecisionStatus, +} from "./decisionRegistry.js"; + +export const DECISION_REGISTRY_SNAPSHOT_VERSION = "simfile.decision-registry.v1" as const; + +export interface DecisionRegistrySnapshotDecision { + readonly decisionId: string; + readonly principal: string; + readonly status: DecisionStatus; + readonly issuedTick: number; + readonly validThroughTick: number; + readonly tokenDigest: string; +} + +export interface DecisionRegistrySnapshot { + readonly version: typeof DECISION_REGISTRY_SNAPSHOT_VERSION; + readonly runId: string; + readonly worldInstanceId: string; + readonly tokenDigestKeyFingerprint: string; + readonly phase: DecisionPhase; + readonly cutoffTick: number | null; + readonly admissionsClosedTick: number | null; + readonly finalizedTick: number | null; + readonly lastTick: number | null; + readonly nextDecisionSequence: number; + readonly decisions: readonly DecisionRegistrySnapshotDecision[]; +} + +export interface ParsedDecisionRegistrySnapshot { + readonly phase: DecisionPhase; + readonly cutoffTick: number | null; + readonly admissionsClosedTick: number | null; + readonly finalizedTick: number | null; + readonly lastTick: number | null; + readonly nextDecisionSequence: number; + readonly decisions: readonly DecisionRegistrySnapshotDecision[]; +} + +interface SnapshotIdentity { + readonly runId: string; + readonly worldInstanceId: string; + readonly tokenDigestKeyFingerprint: string; +} + +export interface DecisionRegistrySnapshotState { + readonly phase: DecisionPhase; + readonly cutoffTick: number | null; + readonly admissionsClosedTick: number | null; + readonly finalizedTick: number | null; + readonly lastTick: number | null; + readonly nextDecisionSequence: number; +} + +export interface DecisionRegistryTestingOptions { + readonly randomBytes: (size: number) => Uint8Array; + readonly maxDecisionSequence?: number; +} + +const SNAPSHOT_KEYS = [ + "version", "runId", "worldInstanceId", "tokenDigestKeyFingerprint", "phase", "cutoffTick", + "admissionsClosedTick", "finalizedTick", "lastTick", "nextDecisionSequence", "decisions", +]; +const DECISION_KEYS = ["decisionId", "principal", "status", "issuedTick", "validThroughTick", "tokenDigest"]; +const ID_PATTERN = /^decision-\d{12}$/; +const DIGEST_PATTERN = /^sha256:[0-9a-f]{64}$/; +const MAX_SEQUENCE = 999_999_999_999; +const TYPED_ARRAY_PROTOTYPE = Object.getPrototypeOf(Uint8Array.prototype); +const BYTE_LENGTH_GETTER = Object.getOwnPropertyDescriptor(TYPED_ARRAY_PROTOTYPE, "byteLength")?.get; +const UINT8_SET = Uint8Array.prototype.set; + +/** Pure parser: it owns no registry state, token material, or configured key. */ +export const parseDecisionRegistrySnapshot = ( + input: unknown, + identity: SnapshotIdentity, +): ParsedDecisionRegistrySnapshot | undefined => { + const value = exactObject(input, SNAPSHOT_KEYS); + if (!hasMatchingIdentity(value, identity) || !hasSnapshotScalars(value)) return undefined; + const candidates = exactArray(value.decisions); + if (candidates === undefined) return undefined; + const decisions = parseDecisions(candidates); + if (decisions === undefined || value.nextDecisionSequence !== decisions.length + 1) return undefined; + if (!isLifecycleConsistent(value, decisions)) return undefined; + return { + phase: value.phase as DecisionPhase, + cutoffTick: value.cutoffTick as number | null, + admissionsClosedTick: value.admissionsClosedTick as number | null, + finalizedTick: value.finalizedTick as number | null, + lastTick: value.lastTick as number | null, + nextDecisionSequence: value.nextDecisionSequence, + decisions, + }; +}; + +export const cloneAndFreezeDecisionRegistrySnapshot = ( + snapshot: DecisionRegistrySnapshot, +): DecisionRegistrySnapshot => { + const decisions = snapshot.decisions.map((decision) => Object.freeze({ ...decision })); + return Object.freeze({ ...snapshot, decisions: Object.freeze(decisions) }); +}; + +export const createDecisionRegistrySnapshot = ( + runId: string, + worldInstanceId: string, + tokenDigestKeyFingerprint: string, + state: DecisionRegistrySnapshotState, + decisions: readonly DecisionAdmission[], +): DecisionRegistrySnapshot => cloneAndFreezeDecisionRegistrySnapshot({ + version: DECISION_REGISTRY_SNAPSHOT_VERSION, + runId, + worldInstanceId, + tokenDigestKeyFingerprint, + phase: state.phase, + cutoffTick: state.cutoffTick, + admissionsClosedTick: state.admissionsClosedTick, + finalizedTick: state.finalizedTick, + lastTick: state.lastTick, + nextDecisionSequence: state.nextDecisionSequence, + decisions: decisions.map(toSnapshotDecision), +}); + +export const createDecisionRegistryInspection = ( + state: DecisionRegistrySnapshotState, + decisions: readonly DecisionAdmission[], +): DecisionRegistryInspection => Object.freeze({ + phase: state.phase, + cutoffTick: state.cutoffTick, + admissionsClosedTick: state.admissionsClosedTick, + finalizedTick: state.finalizedTick, + lastTick: state.lastTick, + nextDecisionSequence: state.nextDecisionSequence, + decisions: Object.freeze(decisions.map((decision) => Object.freeze({ ...decision }))), +}); + +/** Copies a native Uint8Array/Buffer without consulting caller-controlled properties. */ +export const copySafeUint8Array = (input: unknown): Uint8Array | undefined => { + if (nodeTypes.isProxy(input) || !nodeTypes.isUint8Array(input)) return undefined; + const prototype = Object.getPrototypeOf(input); + if (prototype !== Uint8Array.prototype && prototype !== Buffer.prototype || BYTE_LENGTH_GETTER === undefined) { + return undefined; + } + try { + const copy = new Uint8Array(BYTE_LENGTH_GETTER.call(input) as number); + UINT8_SET.call(copy, input); + return copy; + } catch { + return undefined; + } +}; + +/** Reads enumerable data properties from a non-proxy plain object without invoking accessors. */ +export const readDataObject = ( + input: unknown, + required: readonly string[], + optional: readonly string[] = [], +): Record | undefined => { + if (nodeTypes.isProxy(input) || typeof input !== "object" || input === null || + Array.isArray(input) || Object.getPrototypeOf(input) !== Object.prototype) { + return undefined; + } + const output: Record = {}; + for (const key of [...required, ...optional]) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (descriptor === undefined) { + if (required.includes(key)) return undefined; + continue; + } + if (!descriptor.enumerable || !("value" in descriptor)) return undefined; + output[key] = descriptor.value; + } + return output; +}; + +export const parseDecisionRegistryTestingOptions = ( + input: unknown, + maximumSequence: number, +): DecisionRegistryTestingOptions | undefined => { + const value = readDataObject(input, ["randomBytes"], ["maxDecisionSequence"]); + if (value === undefined || typeof value.randomBytes !== "function" || + (value.maxDecisionSequence !== undefined && + (!isDecisionRegistryTick(value.maxDecisionSequence) || value.maxDecisionSequence < 1 || + value.maxDecisionSequence > maximumSequence))) { + return undefined; + } + return { + randomBytes: value.randomBytes as (size: number) => Uint8Array, + maxDecisionSequence: value.maxDecisionSequence as number | undefined, + }; +}; + +const hasMatchingIdentity = ( + value: Record | undefined, + identity: SnapshotIdentity, +): value is Record => value !== undefined && + value.version === DECISION_REGISTRY_SNAPSHOT_VERSION && value.runId === identity.runId && + value.worldInstanceId === identity.worldInstanceId && + value.tokenDigestKeyFingerprint === identity.tokenDigestKeyFingerprint; + +const hasSnapshotScalars = (value: Record): boolean => + isPhase(value.phase) && isNullableTick(value.cutoffTick) && + isNullableTick(value.admissionsClosedTick) && isNullableTick(value.finalizedTick) && + isNullableTick(value.lastTick) && isSequence(value.nextDecisionSequence); + +const parseDecisions = ( + candidates: readonly unknown[], +): DecisionRegistrySnapshotDecision[] | undefined => { + const decisions: DecisionRegistrySnapshotDecision[] = []; + const digests = new Set(); + const previousByPrincipal = new Map(); + let previousIssuedTick = -1; + for (let index = 0; index < candidates.length; index += 1) { + const decision = parseDecision(candidates[index], index + 1); + if (decision === undefined || decision.issuedTick < previousIssuedTick || digests.has(decision.tokenDigest)) { + return undefined; + } + const previous = previousByPrincipal.get(decision.principal); + if (previous?.status === "active" || + (previous?.status === "expired" && decision.issuedTick <= previous.validThroughTick)) { + return undefined; + } + previousIssuedTick = decision.issuedTick; + previousByPrincipal.set(decision.principal, decision); + digests.add(decision.tokenDigest); + decisions.push(decision); + } + return decisions; +}; + +const parseDecision = ( + input: unknown, + sequence: number, +): DecisionRegistrySnapshotDecision | undefined => { + const value = exactObject(input, DECISION_KEYS); + if (value === undefined || value.decisionId !== formatDecisionRegistryId(sequence) || !isDecisionRegistryBinding(value.principal) || + !isStatus(value.status) || !isDecisionRegistryTick(value.issuedTick) || !isDecisionRegistryTick(value.validThroughTick) || + value.validThroughTick < value.issuedTick || !isDigest(value.tokenDigest)) { + return undefined; + } + return { + decisionId: value.decisionId, + principal: value.principal, + status: value.status, + issuedTick: value.issuedTick, + validThroughTick: value.validThroughTick, + tokenDigest: value.tokenDigest, + }; +}; + +const toSnapshotDecision = (decision: DecisionAdmission): DecisionRegistrySnapshotDecision => ({ + decisionId: decision.decisionId, + principal: decision.principal, + status: decision.status, + issuedTick: decision.issuedTick, + validThroughTick: decision.validThroughTick, + tokenDigest: decision.tokenDigest, +}); + +const isLifecycleConsistent = ( + value: Record, + decisions: readonly DecisionRegistrySnapshotDecision[], +): boolean => { + const phase = value.phase as DecisionPhase; + const cutoffTick = value.cutoffTick as number | null; + const admissionsClosedTick = value.admissionsClosedTick as number | null; + const finalizedTick = value.finalizedTick as number | null; + const lastTick = value.lastTick as number | null; + if (!hasValidFrontiers(phase, cutoffTick, admissionsClosedTick, finalizedTick)) return false; + if (lastTick === null) return decisions.length === 0 && phase === "open"; + if (decisions.length === 0 && phase === "open") return false; + if (!isDecisionRegistryTick(lastTick) || hasFutureFrontier(lastTick, cutoffTick, admissionsClosedTick, finalizedTick)) return false; + if (!hasReachableLastTick(phase, cutoffTick, admissionsClosedTick, finalizedTick, lastTick, decisions)) return false; + return decisions.every((decision) => + isDecisionReachable(decision, phase, cutoffTick, admissionsClosedTick, lastTick)); +}; + +const hasReachableLastTick = ( + phase: DecisionPhase, + cutoffTick: number | null, + admissionsClosedTick: number | null, + finalizedTick: number | null, + lastTick: number, + decisions: readonly DecisionRegistrySnapshotDecision[], +): boolean => { + if (phase === "admissions_closed") return lastTick === admissionsClosedTick; + if (phase === "finalized") return lastTick === finalizedTick; + if (phase === "cutoff" && lastTick === cutoffTick) return true; + if (phase === "open" && decisions.some((decision) => decision.issuedTick === lastTick)) return true; + const terminalByPrincipal = new Map(); + for (const decision of decisions) terminalByPrincipal.set(decision.principal, decision); + const latestIssuedTick = decisions.at(-1)?.issuedTick ?? -1; + return [...terminalByPrincipal.values()].some((decision) => { + if ((decision.status === "active" || decision.status === "consumed") + && lastTick <= decision.validThroughTick) return true; + if (decision.status !== "expired" || lastTick <= decision.validThroughTick) return false; + return phase === "cutoff" + ? isDecisionRegistryTick(cutoffTick) && cutoffTick <= decision.validThroughTick + : latestIssuedTick <= decision.validThroughTick; + }); +}; + +const hasValidFrontiers = ( + phase: unknown, + cutoffTick: unknown, + admissionsClosedTick: unknown, + finalizedTick: unknown, +): boolean => phase === "open" ? cutoffTick === null && admissionsClosedTick === null && finalizedTick === null + : phase === "cutoff" ? isDecisionRegistryTick(cutoffTick) && admissionsClosedTick === null && finalizedTick === null + : phase === "admissions_closed" ? isDecisionRegistryTick(cutoffTick) && isDecisionRegistryTick(admissionsClosedTick) && finalizedTick === null + : phase === "finalized" && isDecisionRegistryTick(cutoffTick) && isDecisionRegistryTick(admissionsClosedTick) && isDecisionRegistryTick(finalizedTick); + +const hasFutureFrontier = ( + lastTick: number, + cutoffTick: unknown, + admissionsClosedTick: unknown, + finalizedTick: unknown, +): boolean => [cutoffTick, admissionsClosedTick, finalizedTick].some((tick) => + tick !== null && (!isDecisionRegistryTick(tick) || tick > lastTick)) || + (isDecisionRegistryTick(cutoffTick) && isDecisionRegistryTick(admissionsClosedTick) && cutoffTick > admissionsClosedTick) || + (isDecisionRegistryTick(admissionsClosedTick) && isDecisionRegistryTick(finalizedTick) && admissionsClosedTick > finalizedTick); + +const isDecisionReachable = ( + decision: DecisionRegistrySnapshotDecision, + phase: unknown, + cutoffTick: unknown, + admissionsClosedTick: unknown, + lastTick: number, +): boolean => decision.issuedTick <= lastTick && + (cutoffTick === null || decision.issuedTick <= (cutoffTick as number)) && + (decision.status !== "active" || + (phase !== "admissions_closed" && phase !== "finalized" && lastTick <= decision.validThroughTick)) && + (decision.status !== "expired" || (lastTick > decision.validThroughTick && + ((phase !== "admissions_closed" && phase !== "finalized") || + (isDecisionRegistryTick(admissionsClosedTick) && decision.validThroughTick < admissionsClosedTick)))); + +const exactObject = (input: unknown, keys: readonly string[]): Record | undefined => { + if (nodeTypes.isProxy(input) || typeof input !== "object" || input === null || Array.isArray(input) || + Object.getPrototypeOf(input) !== Object.prototype || Object.getOwnPropertySymbols(input).length > 0) { + return undefined; + } + const ownKeys = Reflect.ownKeys(input); + if (ownKeys.length !== keys.length || !keys.every((key) => ownKeys.includes(key))) return undefined; + const output: Record = {}; + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(input, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) return undefined; + output[key] = descriptor.value; + } + return output; +}; + +const exactArray = (input: unknown): readonly unknown[] | undefined => { + if (nodeTypes.isProxy(input) || !Array.isArray(input) || Object.getPrototypeOf(input) !== Array.prototype || + Object.getOwnPropertySymbols(input).length > 0) { + return undefined; + } + const length = input.length; + const keys = Reflect.ownKeys(input); + if (keys.length !== length + 1 || !keys.includes("length")) return undefined; + const output: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(input, String(index)); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) return undefined; + output.push(descriptor.value); + } + return output; +}; + +export const formatDecisionRegistryId = (sequence: number): string => `decision-${String(sequence).padStart(12, "0")}`; +export const isDecisionRegistryBinding = (value: unknown): value is string => typeof value === "string" && value.trim().length > 0; +export const isDecisionRegistryTick = (value: unknown): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const isNullableTick = (value: unknown): value is number | null => value === null || isDecisionRegistryTick(value); +const isSequence = (value: unknown): value is number => isDecisionRegistryTick(value) && value >= 1 && value <= MAX_SEQUENCE + 1; +const isDigest = (value: unknown): value is string => typeof value === "string" && DIGEST_PATTERN.test(value); +const isPhase = (value: unknown): value is DecisionPhase => + value === "open" || value === "cutoff" || value === "admissions_closed" || value === "finalized"; +const isStatus = (value: unknown): value is DecisionStatus => + value === "active" || value === "consumed" || value === "expired"; diff --git a/src/world/decisionResultReadAdmission.test.ts b/src/world/decisionResultReadAdmission.test.ts new file mode 100644 index 0000000..ba9e84a --- /dev/null +++ b/src/world/decisionResultReadAdmission.test.ts @@ -0,0 +1,222 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import test from "node:test"; + +import { parseWorldSurfaceDefinition } from "../world-surface/index.js"; +import { validWorldSurface } from "../world-surface/definition.test-helper.js"; +import { + createDecisionRegistryForTesting, + reserveDecisionForAct, + type DecisionRegistry, +} from "./decisionRegistry.js"; +import { + createDecisionResultReadAdmission, + type DecisionResultReadRuntimeIdentity, +} from "./decisionResultReadAdmission.js"; +import { compileCapabilityManifests } from "./capabilityManifest.js"; + +const config = (runId = "run-1", worldInstanceId = "instance-1") => ({ + runId, worldInstanceId, tokenDigestKey: new Uint8Array(32).fill(9), +}); +const manifest = (principal = "principal-red", runId = "run-1", worldInstanceId = "instance-1") => + compileCapabilityManifests({ + runId, worldInstanceId, world: { id: "pitch" as never }, + surfaceRegistry: parseWorldSurfaceDefinition(validWorldSurface()), + grants: [{ participant: principal.replace("principal-", ""), principal, entity: "world://pitch/entity/red" as never, + senses: ["world://pitch/sense/vision" as never], affordances: ["world://pitch/affordance/kick" as never] }], + })[0]!.manifest; +const identity = (value = manifest()): DecisionResultReadRuntimeIdentity => ({ + run_id: value.run_id, world_id: value.world.id, world_instance_id: value.world.instance_id, + manifest_digest: value.manifest_digest, state_version: 0, +}); +const registry = (runId = "run-1", instance = "instance-1"): DecisionRegistry => + createDecisionRegistryForTesting(config(runId, instance), { randomBytes: () => new Uint8Array(32).fill(3) }); +const setup = () => { + const decisionRegistry = registry(); + const minted = decisionRegistry.mint({ principal: "principal-red", issuedTick: 0, validThroughTick: 2 }); + const runtimeManifest = manifest(); + const runtimeIdentity = identity(runtimeManifest); + const issuer = createDecisionResultReadAdmission({ registry: decisionRegistry, manifest: runtimeManifest, runtimeAuthority: runtimeIdentity }); + const request = (token = minted.token, extra: Record = {}) => ({ + principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token, atTick: 0, + manifest: runtimeManifest, runtimeAuthority: runtimeIdentity, ...extra, + }); + return { decisionRegistry, minted, runtimeManifest, runtimeIdentity, issuer, request }; +}; + +test("only a consumed token can mint a result-read admission", () => { + const first = setup(); + assert.throws(() => first.issuer.admit(first.request()), /Decision token admission failed/u); + assert.equal(first.decisionRegistry.peekReadAdmission(first.request()).status, "active"); + assert.equal(first.decisionRegistry.admitRead(first.request()).status, "active"); + const before = first.decisionRegistry.snapshot(); + first.decisionRegistry.consumeForAct(first.request()); + const admission = first.issuer.admit(first.request()); + assert.equal(first.issuer.read(admission).decisionId, first.minted.decisionId); + assert.throws(() => first.decisionRegistry.peekReadAdmission(first.request()), /already been consumed/u); + assert.throws(() => first.decisionRegistry.admitRead(first.request()), /already been consumed/u); + assert.throws(() => reserveDecisionForAct(first.decisionRegistry, first.request()), /already been consumed/u); + assert.notDeepEqual(first.decisionRegistry.snapshot(), before); + const consumed = first.decisionRegistry.inspect().decisions[0]; + assert.equal(consumed?.status, "consumed"); + assert.equal(JSON.stringify(admission).includes(first.minted.token), false); +}); + +test("open and cutoff admissions are replayable without registry mutation", () => { + const first = setup(); + first.decisionRegistry.consumeForAct(first.request()); + const before = first.decisionRegistry.snapshot(); + const one = first.issuer.admit(first.request()); + const two = first.issuer.admit(first.request()); + assert.strictEqual(one, two); + assert.deepEqual(first.issuer.read(one), first.issuer.read(two)); + assert.deepEqual(first.decisionRegistry.snapshot(), before); + + const cutoff = setup(); + cutoff.decisionRegistry.consumeForAct(cutoff.request()); + cutoff.decisionRegistry.beginCutoff(0); + assert.equal(cutoff.issuer.read(cutoff.issuer.admit(cutoff.request())).atTick, 0); + const closed = setup(); + closed.decisionRegistry.consumeForAct(closed.request()); + closed.decisionRegistry.beginCutoff(0); + closed.decisionRegistry.closeAdmissions(0); + assert.throws(() => closed.issuer.admit(closed.request()), /Decision admissions are closed/u); + const finalized = setup(); + finalized.decisionRegistry.consumeForAct(finalized.request()); + finalized.decisionRegistry.beginCutoff(0); finalized.decisionRegistry.closeAdmissions(0); finalized.decisionRegistry.finalize(0); + assert.throws(() => finalized.issuer.admit(finalized.request()), /Decision admissions are closed/u); +}); + +test("expiration fails closed and never resurrects consumed authority", () => { + const first = registry(); + const short = first.mint({ principal: "principal-blue", issuedTick: 0, validThroughTick: 0 }); + first.consumeForAct({ principal: "principal-blue", runId: "run-1", worldInstanceId: "instance-1", token: short.token, atTick: 0 }); + const blueManifest = manifest("principal-blue"); + const blueIdentity = { ...identity(blueManifest), state_version: 1 }; + const blueIssuer = createDecisionResultReadAdmission({ registry: first, manifest: blueManifest, runtimeAuthority: blueIdentity }); + assert.throws(() => blueIssuer.admit({ principal: "principal-blue", runId: "run-1", worldInstanceId: "instance-1", token: short.token, atTick: 1, manifest: blueManifest, runtimeAuthority: blueIdentity }), /Decision token has expired/u); + assert.equal(first.inspect().decisions.at(-1)?.status, "consumed"); +}); + +test("binding substitutions, cross-issued authorities, and hostile shapes fail closed", () => { + const first = setup(); + first.decisionRegistry.consumeForAct(first.request()); + const blue = setup(); + blue.decisionRegistry.consumeForAct(blue.request()); + const bad = [ + first.request(first.minted.token, { principal: "principal-blue" }), + first.request(first.minted.token, { runId: "run-2" }), + first.request(first.minted.token, { worldInstanceId: "instance-2" }), + first.request(first.minted.token, { manifest: blue.runtimeManifest }), + first.request(first.minted.token, { runtimeAuthority: blue.runtimeIdentity }), + { ...first.request(), token: "malformed" }, + { ...first.request(), extra: true }, + Object.defineProperty({ ...first.request() }, "token", { enumerable: true, get: () => first.minted.token }), + Object.assign(Object.create(null), first.request()), + new Proxy(first.request(), {}), + ]; + for (const request of bad) assert.throws(() => first.issuer.admit(request), /Invalid result-read admission request|Invalid decision registry input|Decision token admission failed|Decision token has expired/u); + assert.throws(() => first.issuer.read(Object.freeze({})), /Invalid result-read admission/u); + const forged = Object.freeze({}); + assert.throws(() => first.issuer.read(forged), /Invalid result-read admission/u); + const foreignAdmission = blue.issuer.admit(blue.request()); + assert.throws(() => first.issuer.read(foreignAdmission), /Invalid result-read admission/u); + assert.equal(JSON.stringify(first.issuer).includes("registry"), false); +}); + +test("manifest and runtime identity are coherently bound and never public", () => { + const first = setup(); + first.decisionRegistry.consumeForAct(first.request()); + const changed = { ...first.runtimeManifest, manifest_digest: `sha256:${"0".repeat(64)}` }; + assert.throws(() => createDecisionResultReadAdmission({ registry: first.decisionRegistry, manifest: changed, runtimeAuthority: first.runtimeIdentity }), /Invalid result-read admission issuer/u); + const admission = first.issuer.admit(first.request()); + const output = first.issuer.read(admission); + assert.deepEqual(Object.keys(output).sort(), ["atTick", "decisionId", "issuedTick", "manifestDigest", "principal", "runId", "validThroughTick", "worldInstanceId"].sort()); + for (const secret of [first.minted.token, "tokenDigest", "tokenDigestKey", "reserve", "commit", "abort", "registry"]) { + assert.equal(JSON.stringify(output).includes(secret), false, secret); + } + const worldIndex = readFileSync(new URL("./index.ts", import.meta.url), "utf8"); + const rootIndex = readFileSync(new URL("../index.ts", import.meta.url), "utf8"); + assert.equal(`${worldIndex}\n${rootIndex}`.includes("decisionResultReadAdmission"), false); +}); + +test("binds exact manifest and runtime identity references, holder, and tick", () => { + const first = setup(); + first.decisionRegistry.consumeForAct(first.request()); + const manifestClone = JSON.parse(JSON.stringify(first.runtimeManifest)) as typeof first.runtimeManifest; + const identityClone = { ...first.runtimeIdentity }; + for (const request of [ + first.request(first.minted.token, { manifest: manifestClone }), + first.request(first.minted.token, { runtimeAuthority: identityClone }), + first.request(first.minted.token, { atTick: 1 }), + ]) assert.throws(() => first.issuer.admit(request), /Invalid result-read admission request/u); + + const holderMismatch = setup(); + holderMismatch.decisionRegistry.consumeForAct(holderMismatch.request()); + const blueManifest = manifest("principal-blue"); + const blueIdentity = identity(blueManifest); + assert.throws(() => createDecisionResultReadAdmission({ registry: holderMismatch.decisionRegistry, manifest: blueManifest, runtimeAuthority: blueIdentity }).admit({ + principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: holderMismatch.minted.token, + atTick: 0, manifest: blueManifest, runtimeAuthority: blueIdentity, + }), /Invalid result-read admission request/u); + + for (const invalid of [ + { ...first.runtimeManifest, manifest_digest: `sha256:${"0".repeat(64)}` }, + { ...first.runtimeManifest, holder: { ...first.runtimeManifest.holder, entity: "world://pitch/entity/ball" } }, + { ...first.runtimeManifest, surface: { ...first.runtimeManifest.surface, registry_digest: "sha256:bad" } }, + { ...first.runtimeManifest, affordances: [{ ...first.runtimeManifest.affordances[0]!, rejection_codes: ["forged", "forged"] }] }, + ]) assert.throws(() => createDecisionResultReadAdmission({ registry: first.decisionRegistry, manifest: invalid, runtimeAuthority: first.runtimeIdentity }), /Invalid result-read admission issuer/u); +}); + +test("fails closed after mutable canonical manifest or identity mutation", () => { + const first = setup(); + const mutableManifest = JSON.parse(JSON.stringify(first.runtimeManifest)) as any; + const mutableIdentity = { ...first.runtimeIdentity }; + const issuer = createDecisionResultReadAdmission({ registry: first.decisionRegistry, manifest: mutableManifest, runtimeAuthority: mutableIdentity }); + const request = (atTick: number) => ({ principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: first.minted.token, atTick, manifest: mutableManifest, runtimeAuthority: mutableIdentity }); + first.decisionRegistry.consumeForAct(request(0)); + const admission = issuer.admit(request(0)); + const original = issuer.read(admission); + const before = first.decisionRegistry.snapshot(); + mutableIdentity.state_version = 1; + mutableManifest.manifest_digest = `sha256:${"0".repeat(64)}`; + (mutableManifest.affordances[0]!.rejection_codes as string[]).push("forged"); + assert.throws(() => issuer.admit(request(1)), /Invalid result-read admission request/u); + assert.deepEqual(first.decisionRegistry.snapshot(), before); + assert.deepEqual(issuer.read(admission), original); + assert.equal(issuer.read(admission).manifestDigest, first.runtimeManifest.manifest_digest); +}); + +test("does not invoke mutated identity accessors during admission", () => { + const first = setup(); + const mutableIdentity = { ...first.runtimeIdentity }; + const issuer = createDecisionResultReadAdmission({ registry: first.decisionRegistry, manifest: first.runtimeManifest, runtimeAuthority: mutableIdentity }); + const request = () => ({ principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: first.minted.token, + atTick: 0, manifest: first.runtimeManifest, runtimeAuthority: mutableIdentity }); + first.decisionRegistry.consumeForAct(request()); + const before = first.decisionRegistry.snapshot(); + let invocations = 0; + Object.defineProperty(mutableIdentity, "state_version", { enumerable: true, configurable: true, get: () => { invocations += 1; throw new Error("hostile getter"); } }); + assert.throws(() => issuer.admit(request()), /Invalid result-read admission request/u); + assert.equal(invocations, 0); + assert.deepEqual(first.decisionRegistry.snapshot(), before); +}); + +test("issuer markers isolate identical bindings and the verifier seam is write-once", async () => { + const first = setup(); + first.decisionRegistry.consumeForAct(first.request()); + const second = createDecisionResultReadAdmission({ registry: first.decisionRegistry, manifest: first.runtimeManifest, runtimeAuthority: first.runtimeIdentity }); + const admission = first.issuer.admit(first.request()); + assert.strictEqual(first.issuer.admit(first.request()), admission); + assert.throws(() => second.read(admission), /Invalid result-read admission/u); + const reverse = second.admit(first.request()); + assert.throws(() => first.issuer.read(reverse), /Invalid result-read admission/u); + + const moduleText = readFileSync(new URL("./decisionRegistry.ts", import.meta.url), "utf8"); + assert.equal(moduleText.includes("export const verifyConsumedDecisionResultRead"), false); + const admissionModule = await import("./decisionResultReadAdmission.js"); + assert.equal("verifyConsumedDecisionResultRead" in admissionModule, false); + assert.throws(() => admissionModule.registerConsumedDecisionResultVerifier(first.decisionRegistry as object, () => { + throw new Error("forged"); + }), /registration rejected/u); +}); diff --git a/src/world/decisionResultReadAdmission.ts b/src/world/decisionResultReadAdmission.ts new file mode 100644 index 0000000..3f70b1b --- /dev/null +++ b/src/world/decisionResultReadAdmission.ts @@ -0,0 +1,179 @@ +import { types } from "node:util"; +import { parseCapabilityManifest, serializeCapabilityManifest, type CapabilityManifest } from "./capabilityManifest.js"; +import type { DecisionRegistry } from "./decisionRegistry.js"; + +export interface DecisionResultReadVerification { + readonly decisionId: string; + readonly principal: string; + readonly runId: string; + readonly worldInstanceId: string; + readonly issuedTick: number; + readonly validThroughTick: number; + readonly status: "consumed"; + readonly atTick: number; +} +export interface DecisionResultReadRuntimeIdentity { + readonly run_id: string; + readonly world_id: string; + readonly world_instance_id: string; + readonly manifest_digest: string; + readonly state_version: number; +} +export interface DecisionResultReadRequest { + readonly principal: string; + readonly runId: string; + readonly worldInstanceId: string; + readonly token: string; + readonly atTick: number; + readonly manifest: CapabilityManifest; + readonly runtimeAuthority: DecisionResultReadRuntimeIdentity; +} +export interface DecisionResultReadAdmission { + readonly __opaque?: never; +} +export interface DecisionResultReadMetadata { + readonly decisionId: string; + readonly principal: string; + readonly runId: string; + readonly worldInstanceId: string; + readonly atTick: number; + readonly issuedTick: number; + readonly validThroughTick: number; + readonly manifestDigest: string; +} + +export interface DecisionResultReadIssuer { + admit(input: unknown): DecisionResultReadAdmission; + read(input: unknown): DecisionResultReadMetadata; +} + +type IssuerInput = { + readonly registry: DecisionRegistry; + readonly manifest: CapabilityManifest; + readonly canonicalManifest: CapabilityManifest; + readonly canonicalManifestBytes: readonly number[]; + readonly holderPrincipal: string; + readonly runtimeAuthority: DecisionResultReadRuntimeIdentity; + readonly identitySnapshot: DecisionResultReadRuntimeIdentity; +}; +type AdmissionState = DecisionResultReadMetadata & { + readonly marker: object; +}; + +const admissions = new WeakMap(); +const verifiers = new WeakMap DecisionResultReadVerification>(); +export const registerConsumedDecisionResultVerifier = ( + registry: object, + verifier: (input: unknown) => DecisionResultReadVerification, +): void => { + if (verifiers.has(registry) || typeof verifier !== "function") throw new TypeError("Consumed verifier registration rejected."); + verifiers.set(registry, verifier); +}; +const binding = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= 256 && value === value.trim(); +const ownData = (value: unknown, fields: readonly string[]): Record | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value) || types.isProxy(value as object) || + Object.getPrototypeOf(value) !== Object.prototype) return undefined; + const keys = Reflect.ownKeys(value); + if (keys.length !== fields.length || keys.some((key) => typeof key !== "string" || !fields.includes(key))) return undefined; + const output: Record = Object.create(null); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) return undefined; + output[key as string] = descriptor.value; + } + return output; +}; +const parseIdentity = (value: unknown): DecisionResultReadRuntimeIdentity | undefined => { + const source = ownData(value, ["run_id", "world_id", "world_instance_id", "manifest_digest", "state_version"]); + if (source === undefined || !binding(source.run_id) || !binding(source.world_id) || !binding(source.world_instance_id) || + !/^sha256:[a-f0-9]{64}$/u.test(source.manifest_digest as string) || typeof source.state_version !== "number" || + !Number.isSafeInteger(source.state_version) || source.state_version < 0) return undefined; + return source as unknown as DecisionResultReadRuntimeIdentity; +}; +const canonicalManifest = (value: unknown): CapabilityManifest | undefined => { + try { + const bytes = serializeCapabilityManifest(value as CapabilityManifest); + return parseCapabilityManifest(bytes); + } catch { return undefined; } +}; +const equalBytes = (left: readonly number[], right: readonly number[]): boolean => + left.length === right.length && left.every((byte, index) => byte === right[index]); +const sameIdentity = (left: unknown, right: DecisionResultReadRuntimeIdentity): boolean => { + const parsed = parseIdentity(left); + return parsed !== undefined && + parsed.run_id === right.run_id && parsed.world_id === right.world_id && parsed.world_instance_id === right.world_instance_id && + parsed.manifest_digest === right.manifest_digest && parsed.state_version === right.state_version; +}; +const parseIssuerInput = (input: unknown): IssuerInput | undefined => { + const source = ownData(input, ["registry", "manifest", "runtimeAuthority"]); + if (source === undefined || source.registry === null || typeof source.registry !== "object" || + !parseIdentity(source.runtimeAuthority)) return undefined; + const manifest = source.manifest as CapabilityManifest; + const canonical = canonicalManifest(manifest); + const identity = source.runtimeAuthority as DecisionResultReadRuntimeIdentity; + if (canonical === undefined || identity.run_id !== canonical.run_id || identity.world_id !== canonical.world.id || + identity.world_instance_id !== canonical.world.instance_id || identity.manifest_digest !== canonical.manifest_digest + ) return undefined; + const canonicalManifestBytes = serializeCapabilityManifest(canonical); + const canonicalManifestValue = parseCapabilityManifest(canonicalManifestBytes); + const identitySnapshot = Object.freeze({ run_id: canonicalManifestValue.run_id, world_id: canonicalManifestValue.world.id, + world_instance_id: canonicalManifestValue.world.instance_id, manifest_digest: canonicalManifestValue.manifest_digest, + state_version: identity.state_version }); + return { registry: source.registry as DecisionRegistry, manifest, canonicalManifest: canonicalManifestValue, + canonicalManifestBytes: Object.freeze([...canonicalManifestBytes]), holderPrincipal: canonicalManifestValue.holder.principal, + runtimeAuthority: identity, identitySnapshot }; +}; +const parseRequest = (input: unknown, expected: IssuerInput): DecisionResultReadRequest | undefined => { + const source = ownData(input, ["principal", "runId", "worldInstanceId", "token", "atTick", "manifest", "runtimeAuthority"]); + if (source === undefined || !binding(source.principal) || !binding(source.runId) || !binding(source.worldInstanceId) || + typeof source.token !== "string" || !Number.isSafeInteger(source.atTick as number) || (source.atTick as number) < 0 || + source.atTick !== expected.identitySnapshot.state_version || + source.manifest !== expected.manifest || source.runtimeAuthority !== expected.runtimeAuthority) return undefined; + try { + if (!equalBytes(serializeCapabilityManifest(source.manifest as CapabilityManifest), expected.canonicalManifestBytes) || + !sameIdentity(source.runtimeAuthority as DecisionResultReadRuntimeIdentity, expected.identitySnapshot)) return undefined; + } catch { return undefined; } + return { principal: source.principal as string, runId: source.runId as string, worldInstanceId: source.worldInstanceId as string, + token: source.token as string, atTick: source.atTick as number, manifest: source.manifest as CapabilityManifest, + runtimeAuthority: source.runtimeAuthority as DecisionResultReadRuntimeIdentity }; +}; +const metadata = (verified: DecisionResultReadVerification, manifestDigest: string): DecisionResultReadMetadata => + Object.freeze({ decisionId: verified.decisionId, principal: verified.principal, runId: verified.runId, + worldInstanceId: verified.worldInstanceId, atTick: verified.atTick, issuedTick: verified.issuedTick, + validThroughTick: verified.validThroughTick, manifestDigest }); + +export const createDecisionResultReadAdmission = (input: unknown): DecisionResultReadIssuer => { + const expected = parseIssuerInput(input); + if (expected === undefined) throw new TypeError("Invalid result-read admission issuer."); + const verify = verifiers.get(expected.registry as object); + if (verify === undefined) throw new TypeError("Invalid result-read admission issuer."); + const marker = Object.freeze({}); + const byBinding = new Map(); + const admit = (requestInput: unknown): DecisionResultReadAdmission => { + const request = parseRequest(requestInput, expected); + if (request === undefined) throw new TypeError("Invalid result-read admission request."); + const verified = verify({ + principal: request.principal, runId: request.runId, worldInstanceId: request.worldInstanceId, + token: request.token, atTick: request.atTick, + }); + if (verified.principal !== expected.holderPrincipal) throw new TypeError("Invalid result-read admission request."); + const key = `${verified.decisionId}\0${verified.atTick}`; + const existing = byBinding.get(key); + if (existing !== undefined) return existing; + const admission = Object.freeze(Object.create(null)) as DecisionResultReadAdmission; + admissions.set(admission as object, Object.freeze({ + ...metadata(verified, expected.canonicalManifest.manifest_digest), marker, + })); + byBinding.set(key, admission); + return admission; + }; + const read = (input: unknown): DecisionResultReadMetadata => { + if (input === null || typeof input !== "object") throw new TypeError("Invalid result-read admission."); + const state = admissions.get(input as object); + if (state === undefined || state.marker !== marker) throw new TypeError("Invalid result-read admission."); + const { marker: _marker, ...result } = state; + return Object.freeze(result); + }; + return Object.freeze({ admit, read }); +}; diff --git a/src/world/grantAttestation.ts b/src/world/grantAttestation.ts new file mode 100644 index 0000000..680fb2b --- /dev/null +++ b/src/world/grantAttestation.ts @@ -0,0 +1,22 @@ +import type { BoundWorldGrant } from "./grants.js"; + +const issuedBoundGrantSets = new WeakSet(); + +const freezeGrant = (grant: BoundWorldGrant): BoundWorldGrant => Object.freeze({ + participant: grant.participant, + principal: grant.principal, + entity: grant.entity, + senses: Object.freeze([...grant.senses]), + affordances: Object.freeze([...grant.affordances]), +}); + +/** @internal Exact B18 grant-set authority for world-runtime composition. */ +export const issueBoundWorldGrants = (grants: readonly BoundWorldGrant[]): readonly BoundWorldGrant[] => { + const issued = Object.freeze(grants.map(freezeGrant)); + issuedBoundGrantSets.add(issued); + return issued; +}; + +/** @internal Rejects copies, proxies, and every non-issued grant-set value. */ +export const readBoundWorldGrants = (value: unknown): readonly BoundWorldGrant[] | undefined => + value !== null && typeof value === "object" && issuedBoundGrantSets.has(value) ? value as readonly BoundWorldGrant[] : undefined; diff --git a/src/world/grantComposition.test.ts b/src/world/grantComposition.test.ts new file mode 100644 index 0000000..f2a5a0f --- /dev/null +++ b/src/world/grantComposition.test.ts @@ -0,0 +1,138 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseSimfileSource } from "../schema/parse.js"; +import { + parseWorldSurfaceDefinition, + WORLD_SURFACE_API_VERSION +} from "../world-surface/index.js"; +import { + assertWorldGrantManifestCount, + composeWorldGrants, + createLocalWorldGrantPrincipalResolver, + localWorldGrantPrincipal +} from "./grantComposition.js"; + +const surface = () => parseWorldSurfaceDefinition({ + affordances: {}, + api_version: WORLD_SURFACE_API_VERSION, + effects: {}, + entities: { + alpha: { + address: "entity:alpha", + dynamics_address: "object:alpha" + } + }, + senses: { + "sense:open": { + dynamics_senses: ["sense:state"], + output: "simfile.numeric-observation.v1", + project: (input: { + observation: { + channels: readonly { + components: Readonly>; + }[]; + }; + }) => ({ + channels: input.observation.channels.map((channel) => ({ + components: channel.components, + sense_address: "sense:open", + subject_address: "entity:alpha" + })) + }) + } + } +}); + +const world = (sense = "sense:open") => parseSimfileSource(` +simfile_version: "0.1" +name: composition-test +clock: + seed: composition-test + tick: 1s +world: + id: arena + grants: + alpha: + entity: entity:alpha + senses: [${sense}] +`).simfile.world!; + +test("composes resolved, locally bound grants into a participant manifest lookup", () => { + const composition = composeWorldGrants({ + runId: "composition-run", + surfaceRegistry: surface(), + world: world(), + worldInstanceId: "composition-run-world" + }); + + assert.deepEqual(composition.boundGrants, [{ + participant: "alpha", + principal: "participant:alpha", + entity: "world://arena/entity/alpha", + senses: ["world://arena/sense/open"], + affordances: [] + }]); + assert.equal(Object.getPrototypeOf(composition.manifestsByParticipant), null); + assert.deepEqual(Object.keys(composition.manifestsByParticipant), ["alpha"]); + assert.equal( + composition.manifestsByParticipant.alpha?.holder.principal, + "participant:alpha" + ); + assert.equal(composition.artifacts.length, 1); +}); + +test("local participant labels are one-to-one and are not identity claims", () => { + const resolver = createLocalWorldGrantPrincipalResolver(["alpha", "beta"]); + assert.equal(localWorldGrantPrincipal("alpha"), "participant:alpha"); + assert.equal(resolver.resolvePrincipal("beta"), "participant:beta"); + assert.equal(resolver.resolveParticipant("participant:alpha"), "alpha"); + assert.equal(resolver.resolvePrincipal("outsider"), undefined); + assert.equal(resolver.resolveParticipant("participant:outsider"), undefined); + assert.throws( + () => createLocalWorldGrantPrincipalResolver(["alpha", "alpha"]), + /duplicate local world grant participant/u + ); +}); + +test("uses a supplied principal resolver for an externally authenticated runtime", () => { + const composition = composeWorldGrants({ + principalResolver: { + resolveParticipant: (principal) => principal === "agent:alpha" ? "alpha" : undefined, + resolvePrincipal: (participant) => participant === "alpha" ? "agent:alpha" : undefined, + }, + runId: "composition-run", + surfaceRegistry: surface(), + world: world(), + worldInstanceId: "composition-run-world", + }); + + assert.equal(composition.boundGrants[0]?.principal, "agent:alpha"); + assert.equal( + composition.manifestsByParticipant.alpha?.holder.principal, + "agent:alpha", + ); +}); + +test("fails closed on over-broad grants and incomplete compilation output", () => { + assert.throws( + () => composeWorldGrants({ + runId: "composition-run", + surfaceRegistry: surface(), + world: world("sense:absent"), + worldInstanceId: "composition-run-world" + }), + /not declared by the checked world surface/u + ); + + const complete = composeWorldGrants({ + runId: "composition-run", + surfaceRegistry: surface(), + world: world(), + worldInstanceId: "composition-run-world" + }); + assert.throws( + () => assertWorldGrantManifestCount(complete.boundGrants, []), + /expected 1, received 0/u + ); +}); diff --git a/src/world/grantComposition.ts b/src/world/grantComposition.ts new file mode 100644 index 0000000..515a416 --- /dev/null +++ b/src/world/grantComposition.ts @@ -0,0 +1,120 @@ +import { parseSimfileIdentifier } from "../schema/identifier.js"; +import type { SimfileWorld } from "../schema/model.js"; +import type { WorldSurfaceRegistry } from "../world-surface/index.js"; +import { + compileCapabilityManifests, + type CapabilityManifest, + type CapabilityManifestArtifact +} from "./capabilityManifest.js"; +import { + bindWorldGrants, + resolveWorldGrants, + type BoundWorldGrant, + type WorldGrantPrincipalResolver +} from "./grants.js"; + +export interface WorldGrantCompositionInput { + readonly runId: string; + readonly principalResolver?: WorldGrantPrincipalResolver; + readonly surfaceRegistry: WorldSurfaceRegistry; + readonly world: SimfileWorld; + readonly worldInstanceId: string; +} + +export interface WorldGrantComposition { + readonly artifacts: readonly CapabilityManifestArtifact[]; + readonly boundGrants: readonly BoundWorldGrant[]; + readonly manifestsByParticipant: Readonly>; +} + +/** + * Derives a deterministic local participant binding label. This label is + * neither an organization principal nor an agent identity. + */ +export const localWorldGrantPrincipal = (participant: string): string => + `participant:${parseSimfileIdentifier(participant)}`; + +export const createLocalWorldGrantPrincipalResolver = ( + participants: readonly string[] +): WorldGrantPrincipalResolver => { + const principalByParticipant = new Map(); + const participantByPrincipal = new Map(); + for (const rawParticipant of participants) { + const participant = parseSimfileIdentifier(rawParticipant); + const principal = localWorldGrantPrincipal(participant); + if ( + principalByParticipant.has(participant) + || participantByPrincipal.has(principal) + ) { + throw new TypeError(`duplicate local world grant participant ${participant}`); + } + principalByParticipant.set(participant, principal); + participantByPrincipal.set(principal, participant); + } + return Object.freeze({ + resolvePrincipal: (participant: string) => + principalByParticipant.get(participant), + resolveParticipant: (principal: string) => + participantByPrincipal.get(principal) + }); +}; + +export const assertWorldGrantManifestCount = ( + grants: readonly BoundWorldGrant[], + artifacts: readonly CapabilityManifestArtifact[] +): void => { + if (artifacts.length !== grants.length) { + throw new Error( + "world grant manifest compilation was incomplete: " + + `expected ${grants.length}, received ${artifacts.length}` + ); + } +}; + +export const composeWorldGrants = ( + input: WorldGrantCompositionInput +): WorldGrantComposition => { + const resolved = resolveWorldGrants(input.world, input.surfaceRegistry); + const boundGrants = bindWorldGrants( + resolved, + input.principalResolver ?? createLocalWorldGrantPrincipalResolver( + resolved.map((grant) => grant.participant) + ) + ); + const artifacts = compileCapabilityManifests({ + grants: boundGrants, + runId: input.runId, + surfaceRegistry: input.surfaceRegistry, + world: { id: input.world.id }, + worldInstanceId: input.worldInstanceId + }); + assertWorldGrantManifestCount(boundGrants, artifacts); + + const artifactByPrincipal = new Map( + artifacts.map((artifact) => [ + artifact.manifest.holder.principal, + artifact + ]) + ); + const manifests = Object.create(null) as Record; + for (const grant of boundGrants) { + const artifact = artifactByPrincipal.get(grant.principal); + if (artifact === undefined) { + throw new Error( + `world grant manifest is missing for participant ${grant.participant}` + ); + } + Object.defineProperty(manifests, grant.participant, { + enumerable: true, + value: artifact.manifest + }); + } + if (Object.keys(manifests).length !== boundGrants.length) { + throw new Error("world grant participant manifest lookup is incomplete"); + } + return Object.freeze({ + artifacts, + boundGrants, + manifestsByParticipant: Object.freeze(manifests) + }); +}; diff --git a/src/world/grants.test.ts b/src/world/grants.test.ts new file mode 100644 index 0000000..cdf110f --- /dev/null +++ b/src/world/grants.test.ts @@ -0,0 +1,159 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { parseSimfileSource } from "../schema/parse.js"; +import { parseWorldSurfaceDefinition } from "../world-surface/index.js"; +import { validWorldSurface } from "../world-surface/definition.test-helper.js"; +import { + bindWorldGrantPrincipals, + bindWorldGrants, + resolveWorldGrants, + type WorldGrantPrincipalResolver +} from "./index.js"; + +const worldFrom = (grants: string) => parseSimfileSource(` +simfile_version: "0.1" +name: grants-test +clock: + seed: grants-test + tick: 1s +world: + id: pitch + grants: +${grants}`).simfile.world!; + +const surface = () => parseWorldSurfaceDefinition(validWorldSurface()); + +const resolver = (entries: Readonly>): WorldGrantPrincipalResolver => ({ + resolvePrincipal: (participant) => entries[participant], + resolveParticipant: (principal) => Object.entries(entries) + .find(([, candidate]) => candidate === principal)?.[0] +}); + +test("resolves declared local grants without an organization resolver", () => { + const world = worldFrom(` + red: + entity: entity:red + senses: [sense:vision] + affordances: [affordance:kick] + blue: + entity: entity:ball +`); + const grants = resolveWorldGrants(world, surface()); + + assert.deepEqual(grants, [{ + participant: "blue", + entity: "world://pitch/entity/ball", + senses: [], + affordances: [] + }, { + participant: "red", + entity: "world://pitch/entity/red", + senses: ["world://pitch/sense/vision"], + affordances: ["world://pitch/affordance/kick"] + }]); + assert.ok(Object.isFrozen(grants)); + assert.ok(Object.isFrozen(grants[0])); + assert.ok(Object.isFrozen(grants[1]!.senses)); + assert.throws(() => (grants as unknown as unknown[]).push({}), TypeError); + assert.throws(() => (grants[1]!.senses as unknown as string[]).push("world://pitch/sense/other"), TypeError); + assert.ok(grants.flatMap((grant) => [grant.entity, ...grant.senses, ...grant.affordances]) + .every((address) => address.startsWith("world://pitch/"))); + + const mutableWorld = { + ...world, + grants: { red: { ...world.grants.red!, senses: ["sense:vision"], affordances: [] } } + } as unknown as { grants: { red: { senses: string[] } } }; + const isolated = resolveWorldGrants(mutableWorld as never, surface()); + mutableWorld.grants.red.senses.push("sense:missing"); + assert.deepEqual(isolated[0]!.senses, ["world://pitch/sense/vision"]); +}); + +test("rejects undeclared resources and never treats effects as grantable", () => { + for (const grants of [ + ` red:\n entity: entity:missing`, + ` red:\n entity: entity:red\n senses: [sense:missing]`, + ` red:\n entity: entity:red\n affordances: [affordance:missing]` + ]) assert.throws(() => resolveWorldGrants(worldFrom(grants), surface()), /not declared/u); + + const world = worldFrom(` + red: + entity: entity:red +`); + const grants = resolveWorldGrants(world, surface()); + assert.deepEqual(grants[0], { + participant: "red", + entity: "world://pitch/entity/red", + senses: [], + affordances: [] + }); + for (const field of ["senses", "affordances"]) { + assert.throws(() => resolveWorldGrants({ + ...world, + grants: { red: { ...world.grants.red!, [field]: ["effect:impact"] } } + } as never, surface()), /local (sense|affordance): reference/u); + } +}); + +test("rejects hostile canonical, cross-world, wrong-kind, and duplicate grants at resolution", () => { + const world = worldFrom(` + red: + entity: entity:red + senses: [sense:vision] + affordances: [affordance:kick] +`); + for (const mutation of [ + { entity: "world://other/entity/red" }, + { entity: "world://pitch/entity/red" }, + { entity: "sense:vision" }, + { senses: ["sense:vision", "sense:vision"] }, + { affordances: ["affordance:kick", "affordance:kick"] } + ]) { + assert.throws(() => resolveWorldGrants({ + ...world, + grants: { red: { ...world.grants.red!, ...mutation } } + } as never, surface())); + } + + assert.throws(() => resolveWorldGrants({ + ...world, + grants: { " red ": world.grants.red! } + } as never, surface()), /expected lowercase identifier/u); +}); + +test("binds grants only through a one-to-one, round-trippable principal mapping", () => { + const grants = resolveWorldGrants(worldFrom(` + red: + entity: entity:red + blue: + entity: entity:ball +`), surface()); + const bound = bindWorldGrants(grants, resolver({ blue: "principal-blue", red: "principal-red" })); + assert.deepEqual(bound.map(({ participant, principal }) => ({ participant, principal })), [ + { participant: "blue", principal: "principal-blue" }, + { participant: "red", principal: "principal-red" } + ]); + assert.ok(Object.isFrozen(bound)); + assert.ok(Object.isFrozen(bound[0]!.affordances)); + + assert.throws(() => bindWorldGrants(grants, resolver({ red: "principal-red" })), /has no principal/u); + assert.throws(() => bindWorldGrants(grants, resolver({ + blue: "principal-shared", red: "principal-shared" + })), /bound more than once/u); + assert.throws(() => bindWorldGrants(grants, { + resolvePrincipal: (participant) => participant === "red" ? "principal-blue" : "principal-red", + resolveParticipant: () => "blue" + }), /does not round-trip/u); + assert.throws(() => bindWorldGrants(grants, { + resolvePrincipal: () => "", + resolveParticipant: () => "red" + }), /has no principal/u); + assert.throws(() => bindWorldGrants(grants, { + resolvePrincipal: () => " ", + resolveParticipant: () => "red" + }), /has no principal/u); + assert.throws(() => bindWorldGrantPrincipals(grants, { + resolvePrincipal: () => " principal-red ", + resolveParticipant: () => "red" + }), /has no principal/u); +}); diff --git a/src/world/grants.ts b/src/world/grants.ts new file mode 100644 index 0000000..7e8623d --- /dev/null +++ b/src/world/grants.ts @@ -0,0 +1,165 @@ +import type { SimfileWorld } from "../schema/model.js"; +import { parseSimfileIdentifier } from "../schema/identifier.js"; +import type { WorldSurfaceRegistry } from "../world-surface/index.js"; +import { + parseLocalResourceReference, + parseWorldId, + resolveWorldAddress, + type CanonicalWorldAddress, + type LocalResourceReference +} from "./addresses.js"; +import { issueBoundWorldGrants } from "./grantAttestation.js"; + +type GrantableResourceKind = "entity" | "sense" | "affordance"; + +export interface ResolvedWorldGrant { + readonly participant: string; + readonly entity: CanonicalWorldAddress; + readonly senses: readonly CanonicalWorldAddress[]; + readonly affordances: readonly CanonicalWorldAddress[]; +} + +export interface BoundWorldGrant extends ResolvedWorldGrant { + readonly principal: string; +} + +export interface WorldGrantPrincipalResolver { + resolvePrincipal(participant: string): string | undefined; + resolveParticipant(principal: string): string | undefined; +} + +const localReferenceOfKind = ( + value: unknown, + kind: GrantableResourceKind, + path: string +): LocalResourceReference => { + let reference: LocalResourceReference; + try { + reference = parseLocalResourceReference(value); + } catch (error) { + throw new TypeError(`${path} must be a local ${kind}: reference`, { cause: error }); + } + if (!reference.startsWith(`${kind}:`)) { + throw new TypeError(`${path} must be a local ${kind}: reference`); + } + return reference; +}; + +const declaredReference = ( + reference: LocalResourceReference, + declarations: readonly { readonly address: LocalResourceReference }[], + path: string +): LocalResourceReference => { + if (!declarations.some((declaration) => declaration.address === reference)) { + throw new TypeError(`${path} is not declared by the checked world surface`); + } + return reference; +}; + +const uniqueReferences = ( + references: readonly unknown[], + kind: "sense" | "affordance", + declarations: readonly { readonly address: LocalResourceReference }[], + participant: string +): readonly LocalResourceReference[] => { + const parsed: LocalResourceReference[] = []; + const seen = new Set(); + for (const [index, value] of references.entries()) { + const path = `world.grants.${participant}.${kind}s[${index}]`; + const reference = declaredReference(localReferenceOfKind(value, kind, path), declarations, path); + if (seen.has(reference)) throw new TypeError(`${path} duplicates a ${kind} grant`); + seen.add(reference); + parsed.push(reference); + } + return parsed; +}; + +const resolvedReferences = ( + worldId: ReturnType, + references: readonly LocalResourceReference[] +): readonly CanonicalWorldAddress[] => Object.freeze( + references.map((reference) => resolveWorldAddress({ id: worldId }, reference)) +); + +/** + * Resolves parsed, authored grants against a checked B16 world-surface registry. + * This is deliberately independent of organization/principal identity binding. + */ +export const resolveWorldGrants = ( + world: SimfileWorld, + surfaceRegistry: WorldSurfaceRegistry +): readonly ResolvedWorldGrant[] => { + const worldId = parseWorldId(world.id); + const grants = world.grants; + const records: ResolvedWorldGrant[] = []; + + for (const participant of Object.keys(grants).map(parseSimfileIdentifier).sort()) { + const grant = grants[participant]!; + const entityPath = `world.grants.${participant}.entity`; + const entityReference = declaredReference( + localReferenceOfKind(grant.entity, "entity", entityPath), + surfaceRegistry.entities, + entityPath + ); + const senses = uniqueReferences( + grant.senses, + "sense", + surfaceRegistry.senses, + participant + ); + const affordances = uniqueReferences( + grant.affordances, + "affordance", + surfaceRegistry.affordances, + participant + ); + records.push(Object.freeze({ + participant, + entity: resolveWorldAddress({ id: worldId }, entityReference), + senses: resolvedReferences(worldId, senses), + affordances: resolvedReferences(worldId, affordances) + })); + } + + return Object.freeze(records); +}; + +/** + * Binds standalone grant records to an injected, round-trippable organization + * principal mapping. No organization implementation is imported here. + */ +export const bindWorldGrants = ( + grants: readonly ResolvedWorldGrant[], + resolver: WorldGrantPrincipalResolver +): readonly BoundWorldGrant[] => { + if (!resolver || typeof resolver.resolvePrincipal !== "function" + || typeof resolver.resolveParticipant !== "function") { + throw new TypeError("World grant binding requires a two-way principal resolver"); + } + + const principals = new Set(); + const bound: BoundWorldGrant[] = []; + for (const grant of grants) { + const principal = resolver.resolvePrincipal(grant.participant); + if (typeof principal !== "string" || principal.length === 0 || principal !== principal.trim()) { + throw new TypeError(`world grant participant ${grant.participant} has no principal`); + } + if (principals.has(principal)) { + throw new TypeError(`world grant principal ${principal} is bound more than once`); + } + if (resolver.resolveParticipant(principal) !== grant.participant) { + throw new TypeError(`world grant principal ${principal} does not round-trip to ${grant.participant}`); + } + principals.add(principal); + bound.push(Object.freeze({ + participant: grant.participant, + principal, + entity: grant.entity, + senses: Object.freeze([...grant.senses]), + affordances: Object.freeze([...grant.affordances]) + })); + } + return issueBoundWorldGrants(bound); +}; + +export const bindWorldGrantPrincipals = bindWorldGrants; diff --git a/src/world/hostileJson.test.ts b/src/world/hostileJson.test.ts new file mode 100644 index 0000000..5795437 --- /dev/null +++ b/src/world/hostileJson.test.ts @@ -0,0 +1,100 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { copyHostileJson } from "./hostileJson.js"; + +const rejectsWithoutTrap = (value: unknown, traps: { count: number }): void => { + assert.throws(() => copyHostileJson(value)); + assert.equal(traps.count, 0); +}; + +test("copies accepted JSON into canonical isolated frozen data", () => { + const source = { zed: { value: 1 }, first: [{ value: 2 }, { value: 3 }] }; + const copy = copyHostileJson(source) as Record; + assert.deepEqual(Object.keys(copy), ["first", "zed"]); + assert.equal(Object.getPrototypeOf(copy), null); + assert.equal(Object.getPrototypeOf(copy.zed), null); + assert(Object.isFrozen(copy)); + assert(Object.isFrozen(copy.zed)); + assert(Object.isFrozen(copy.first)); + assert.equal(Object.getPrototypeOf(copy.first[0]), null); + assert.equal(Object.getPrototypeOf(copy.first), Array.prototype); + source.zed.value = 9; + source.first[0]!.value = 8; + assert.equal(copy.first[0].value, 2); + assert.equal(copy.first[1].value, 3); + assert.equal(copy.zed.value, 1); + assert.notEqual(copy.first[0], copy.first[1]); +}); + +test("rejects reflection hazards without invoking hostile code", () => { + const traps = { count: 0 }; + const proxy = new Proxy({}, { + get: () => { traps.count += 1; return 1; }, + getOwnPropertyDescriptor: () => { traps.count += 1; return undefined; }, + ownKeys: () => { traps.count += 1; return []; }, + }); + rejectsWithoutTrap(proxy, traps); + + const getter = Object.create(null) as Record; + Object.defineProperty(getter, "value", { + enumerable: true, + get: () => { traps.count += 1; return 1; }, + }); + rejectsWithoutTrap(getter, traps); + + const symbol = { value: 1 } as Record; + symbol[Symbol("authority")] = true; + rejectsWithoutTrap(symbol, traps); + rejectsWithoutTrap(Object.assign(Object.create({ inherited: 1 }), { value: 1 }), traps); + rejectsWithoutTrap(Object.create({ then: 1 }), traps); + rejectsWithoutTrap({ then: 1 }, traps); + const previousThen = Object.getOwnPropertyDescriptor(Object.prototype, "then"); + Object.defineProperty(Object.prototype, "then", { configurable: true, value: 1 }); + try { + rejectsWithoutTrap({ value: 1 }, traps); + } finally { + if (previousThen === undefined) delete (Object.prototype as { then?: unknown }).then; + else Object.defineProperty(Object.prototype, "then", previousThen); + } + rejectsWithoutTrap({ __proto__: null, constructor: 1 }, traps); + rejectsWithoutTrap({ __proto__: null, prototype: 1 }, traps); +}); + +test("rejects malformed arrays, aliases, cycles, and unsupported values", () => { + const traps = { count: 0 }; + rejectsWithoutTrap([, 1], traps); + const nonIndex = [1]; + Object.defineProperty(nonIndex, "01", { enumerable: true, value: 1 }); + rejectsWithoutTrap(nonIndex, traps); + const shared = { value: 1 }; + rejectsWithoutTrap([shared, shared], traps); + const cycle: Record = {}; + cycle.self = cycle; + rejectsWithoutTrap(cycle, traps); + for (const value of [undefined, Symbol("x"), 1n, () => 1, NaN, Infinity, -Infinity]) { + rejectsWithoutTrap(value, traps); + } +}); + +test("enforces depth, node, individual-string, and cumulative budgets", () => { + const traps = { count: 0 }; + let deep: Record = {}; + const root = deep; + for (let index = 0; index <= DYNAMICS_LIMITS.json_depth; index += 1) { + const next: Record = {}; + deep.child = next; + deep = next; + } + rejectsWithoutTrap(root, traps); + rejectsWithoutTrap(Array.from({ length: DYNAMICS_LIMITS.json_nodes }, () => null), traps); + rejectsWithoutTrap("x".repeat(DYNAMICS_LIMITS.json_string_length + 1), traps); + const cumulative: Record = Object.create(null); + const chunk = "x".repeat(DYNAMICS_LIMITS.json_string_length); + cumulative.a = chunk; + cumulative.b = chunk; + cumulative.c = chunk; + cumulative.d = chunk; + rejectsWithoutTrap(cumulative, traps); + assert.equal(traps.count, 0); +}); diff --git a/src/world/hostileJson.ts b/src/world/hostileJson.ts new file mode 100644 index 0000000..07fb788 --- /dev/null +++ b/src/world/hostileJson.ts @@ -0,0 +1,102 @@ +import { types } from "node:util"; + +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; + +type HostileJsonPrimitive = null | boolean | number | string; +interface HostileJsonObject { readonly [key: string]: HostileJson; } +export type HostileJson = HostileJsonPrimitive | readonly HostileJson[] | Readonly; + +interface Budget { + codeUnits: number; + nodes: number; +} + +const DANGEROUS_KEYS = new Set(["__proto__", "constructor", "prototype"]); +const INDEX = /^(?:0|[1-9][0-9]*)$/u; + +const fail = (): never => { throw new TypeError("hostile JSON rejected"); }; +const frozen = (value: Value): Value => Object.freeze(value); +const isProxy = (value: object): boolean => types.isProxy(value); + +const addNode = (budget: Budget, depth: number): void => { + if (depth > DYNAMICS_LIMITS.json_depth || ++budget.nodes > DYNAMICS_LIMITS.json_nodes) fail(); +}; + +const addText = (value: string, budget: Budget): void => { + if (value.length > DYNAMICS_LIMITS.json_string_length) fail(); + budget.codeUnits += value.length; + if (budget.codeUnits > DYNAMICS_LIMITS.json_code_units) fail(); +}; + +/** Scans descriptors only, including the prototype chain, after proxy rejection. */ +const hasThen = (value: object): boolean => { + let current: object | null = value; + while (current !== null) { + if (isProxy(current) || Object.getOwnPropertyDescriptor(current, "then") !== undefined) return true; + current = Object.getPrototypeOf(current); + } + return false; +}; + +const arrayLength = (value: unknown[]): number => { + const descriptor = Object.getOwnPropertyDescriptor(value, "length"); + if (descriptor === undefined || !("value" in descriptor)) fail(); + const length = (descriptor as PropertyDescriptor & { value: unknown }).value; + if (!Number.isSafeInteger(length) || length < 0 || length > DYNAMICS_LIMITS.json_nodes) fail(); + return length; +}; + +const copyArray = (value: unknown[], seen: Set, depth: number, budget: Budget): HostileJson => { + if (Object.getPrototypeOf(value) !== Array.prototype || hasThen(value)) fail(); + const length = arrayLength(value); + const keys = Reflect.ownKeys(value); + if (keys.length !== length + 1 || !keys.includes("length") || keys.some((key) => + typeof key !== "string" || (key !== "length" && (!INDEX.test(key) || Number(key) >= length)))) fail(); + const copy: HostileJson[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) fail(); + copy.push(copyValue((descriptor as PropertyDescriptor & { value: unknown }).value, seen, depth + 1, budget)); + } + return frozen(copy); +}; + +const copyObject = (value: object, seen: Set, depth: number, budget: Budget): HostileJson => { + const prototype = Object.getPrototypeOf(value); + if ((prototype !== Object.prototype && prototype !== null) || hasThen(value)) fail(); + const entries: Array = []; + for (const key of Reflect.ownKeys(value)) { + if (typeof key !== "string") fail(); + const textKey = key as string; + if (DANGEROUS_KEYS.has(textKey)) fail(); + addText(textKey, budget); + const descriptor = Object.getOwnPropertyDescriptor(value, textKey); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) fail(); + entries.push([textKey, copyValue((descriptor as PropertyDescriptor & { value: unknown }).value, seen, depth + 1, budget)]); + } + entries.sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0); + const copy = Object.create(null) as Record; + for (const [key, child] of entries) Object.defineProperty(copy, key, { + configurable: false, enumerable: true, value: child, writable: false, + }); + return frozen(copy); +}; + +const copyValue = (value: unknown, seen: Set, depth: number, budget: Budget): HostileJson => { + addNode(budget, depth); + if (value === null || typeof value === "boolean") return value; + if (typeof value === "string") { addText(value, budget); return value; } + if (typeof value === "number") return Number.isFinite(value) ? (Object.is(value, -0) ? 0 : value) : fail(); + if (typeof value !== "object" || value === null) fail(); + const source = value as object; + if (isProxy(source) || seen.has(source)) fail(); + seen.add(source); + return Array.isArray(source) ? copyArray(source, seen, depth, budget) : copyObject(source, seen, depth, budget); +}; + +/** + * Copies an untrusted JSON graph without evaluating user code. The visited set + * is deliberately never unwound: repeated aliases and cycles are both invalid. + */ +export const copyHostileJson = (input: unknown): HostileJson => + copyValue(input, new Set(), 0, { codeUnits: 0, nodes: 0 }); diff --git a/src/world/index.ts b/src/world/index.ts new file mode 100644 index 0000000..a32d9d7 --- /dev/null +++ b/src/world/index.ts @@ -0,0 +1,71 @@ +export * from "./addresses.js"; +export * from "./grants.js"; +export { + CAPABILITY_MANIFEST_VERSION, + compileCapabilityManifests, + parseCapabilityManifest, + serializeCapabilityManifest, +} from "./capabilityManifest.js"; +export type { + CapabilityManifest, + CapabilityManifestArtifact, + CapabilityManifestCompilationInput, +} from "./capabilityManifest.js"; +export { createDecisionRegistry, DecisionRegistryError } from "./decisionRegistry.js"; +export { DECISION_REGISTRY_SNAPSHOT_VERSION } from "./decisionRegistrySnapshot.js"; +export type { + DecisionAdmission, + DecisionAdmissionRequest, + DecisionReadAdmission, + DecisionMintRequest, + DecisionMintResult, + DecisionPhase, + DecisionRegistry, + DecisionRegistryConfig, + DecisionRegistryErrorCode, + DecisionRegistryInspection, + DecisionStatus, +} from "./decisionRegistry.js"; +export type { DecisionRegistrySnapshot, DecisionRegistrySnapshotDecision } from "./decisionRegistrySnapshot.js"; +export { createWorldRuntime } from "./runtime.js"; +export { composeWorldRuntimeInput } from "./runtimeComposition.js"; +export type { WorldObserveRequest, WorldRuntimeObservation } from "./observe.js"; +export type { WorldRuntimeAffordance, WorldRuntimeAffordances } from "./affordances.js"; +export type { WorldActIngressReceipt, WorldActIngressRejection, WorldActIngressRejectionReason, WorldActQueuedReceipt } from "./actTypes.js"; +export { createWorldReadLedger, parseWorldReadLedgerRequest, WORLD_READ_OPERATIONS, WorldRuntimeError } from "./ledger.js"; +export type { + AuthenticatedWorldContext, + CreateWorldRuntimeInput, + WorldRuntime, + WorldRuntimeCapabilities, + WorldRuntimeIdentity, + WorldRuntimeLedger, + WorldRuntimeStatus, +} from "./runtime.js"; +export type { + WorldReadIdentity, + WorldReadLedger, + WorldReadLedgerOptions, + WorldReadLedgerPage, + WorldReadLedgerRecord, + WorldReadOperation, + WorldRuntimeErrorCode, +} from "./ledger.js"; +export { WORLD_ACTION_RESULT_VERSION, parseWorldActionResult } from "./actionResult.js"; +export type { WorldActionResult, WorldActionResultApplied, WorldActionResultIdentity, WorldActionResultRejected } from "./actionResult.js"; +export type { WorldActionResultCursor, WorldActionResultPage, WorldActionResultPageRequest } from "./actionResultLedger.js"; +export { + encodeWorldActEnvelope, + parseWorldActEnvelope, + tryParseWorldActEnvelope, + WORLD_ACT_ENVELOPE_VERSION, + type ParsedWorldActEnvelope, + type WorldActEnvelopeInput, +} from "./actEnvelope.js"; +export { + cloneWorldCheckpoint, + parseWorldCheckpoint, + WORLD_CHECKPOINT_VERSION, + type WorldCheckpoint, + type WorldCheckpointStatic, +} from "./checkpoint.js"; diff --git a/src/world/ledger.test.ts b/src/world/ledger.test.ts new file mode 100644 index 0000000..95f1a95 --- /dev/null +++ b/src/world/ledger.test.ts @@ -0,0 +1,77 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createWorldReadLedger, readWorldReadLedger, WorldRuntimeError } from "./ledger.js"; + +const writer = (ledger: ReturnType) => readWorldReadLedger(ledger)!; + +test("uses private per-principal sequences, filters, and immutable pages", () => { + const ledger = createWorldReadLedger({ maxEntriesPerPrincipal: 3 }); + writer(ledger).append({ operation: "status", principal: "red", result: "denied" }); + writer(ledger).append({ operation: "affordances", principal: "red", result: "denied" }); + writer(ledger).append({ operation: "capabilities", principal: "blue", result: "denied" }); + writer(ledger).append({ operation: "ledger", principal: "red", result: "allowed", decision_id: "decision-000000000001", state_version: 2, + identity: { run_id: "run-1", world_id: "pitch", world_instance_id: "one", manifest_digest: `sha256:${"a".repeat(64)}`, state_version: 2 } }); + assert.deepEqual(ledger.read("red", { after: 0, limit: 1 }), { + records: [{ sequence: 1, operation: "status", principal: "red", result: "denied" }], next_after: 1, + }); + const page = ledger.read("red", { after: 2, limit: 50, operations: ["ledger"] }); + assert.equal(page.records[0]?.sequence, 3); + assert.equal(page.records[0]?.principal, "red"); + assert.throws(() => (page.records as unknown as unknown[]).push({}), TypeError); + assert.throws(() => ((page.records[0]!.identity as unknown) as { state_version: number }).state_version = 9, TypeError); + assert.deepEqual(ledger.read("red", { operations: ["affordances"] }).records.map((record) => record.operation), ["affordances"]); +}); + +test("rejects hostile pagination shapes without evaluating accessors or proxies", () => { + const ledger = createWorldReadLedger(); + let called = 0; + const accessor: Record = {}; + Object.defineProperty(accessor, "after", { enumerable: true, get: () => { called += 1; return 0; } }); + const attempts = [ + accessor, + new Proxy({ after: 0 }, { getPrototypeOf: () => { called += 1; throw new Error("trap"); } }), + { after: -1 }, { after: 0.5 }, { limit: 101 }, { operations: ["status", "status"] }, { unexpected: true }, + ]; + for (const attempt of attempts) assert.throws(() => ledger.read("red", attempt), (error: unknown) => error instanceof WorldRuntimeError && error.code === "world_runtime_denied"); + assert.equal(called, 0); +}); + +test("bounds principals, retained strings, and per-principal history without corrupting partitions", () => { + const ledger = createWorldReadLedger({ maxEntriesPerPrincipal: 2, maxPrincipals: 2 }); + writer(ledger).append({ operation: "status", principal: "red", result: "denied" }); + writer(ledger).append({ operation: "ledger", principal: "red", result: "denied" }); + writer(ledger).append({ operation: "status", principal: "red", result: "denied" }); + writer(ledger).append({ operation: "status", principal: "blue", result: "denied" }); + assert.deepEqual(ledger.read("red", {}).records.map((record) => record.sequence), [2, 3]); + assert.throws(() => writer(ledger).append({ operation: "status", principal: "green", result: "denied" }), WorldRuntimeError); + assert.deepEqual(ledger.read("blue", {}).records.map((record) => record.sequence), [1]); + assert.throws(() => writer(ledger).append({ operation: "status", principal: "x".repeat(257), result: "denied" }), WorldRuntimeError); + assert.throws(() => writer(ledger).append({ operation: "status", principal: "red", result: "allowed", decision_id: "decision-not-canonical" }), WorldRuntimeError); +}); + +test("issues a frozen read-only public handle and enforces allowed-record invariants", () => { + const ledger = createWorldReadLedger(); + assert.equal("append" in ledger, false); + assert.ok(Object.isFrozen(ledger)); + assert.throws(() => (ledger as unknown as { append: unknown }).append = () => {}, TypeError); + for (const record of [ + { operation: "status", principal: "red", result: "denied", decision_id: "decision-000000000001" }, + { operation: "status", principal: "red", result: "allowed", decision_id: "decision-000000000001", state_version: 2 }, + { operation: "status", principal: "red", result: "allowed", decision_id: "decision-000000000001", state_version: 2, + identity: { run_id: "run-1", world_id: "pitch", world_instance_id: "one", manifest_digest: `sha256:${"a".repeat(64)}`, state_version: 3 } }, + ]) assert.throws(() => writer(ledger).append(record as never), WorldRuntimeError); +}); + +test("reserves known principals atomically and rejects contaminating partitions", () => { + const undersized = createWorldReadLedger({ maxPrincipals: 1 }); + assert.throws(() => writer(undersized).reservePrincipals(["red", "blue"]), WorldRuntimeError); + writer(undersized).reservePrincipals(["red"]); + writer(undersized).append({ operation: "status", principal: "red", result: "denied" }); + assert.equal(undersized.read("red", {}).records.length, 1); + + const contaminated = createWorldReadLedger({ maxPrincipals: 3 }); + writer(contaminated).append({ operation: "status", principal: "unknown", result: "denied" }); + assert.throws(() => writer(contaminated).reservePrincipals(["red", "blue"]), WorldRuntimeError); + assert.deepEqual(contaminated.read("unknown", {}).records.map((record) => record.sequence), [1]); +}); diff --git a/src/world/ledger.ts b/src/world/ledger.ts new file mode 100644 index 0000000..fdf89a2 --- /dev/null +++ b/src/world/ledger.ts @@ -0,0 +1,208 @@ +import { types } from "node:util"; +import { cloneWorldReadLedgerSnapshot, parseWorldReadLedgerSnapshot, type WorldReadLedgerSnapshot } from "./readLedgerSnapshot.js"; + +export const WORLD_READ_OPERATIONS = ["status", "capabilities", "observe", "affordances", "ledger"] as const; +export type WorldReadOperation = typeof WORLD_READ_OPERATIONS[number]; +export type WorldRuntimeErrorCode = "world_runtime_denied" | "world_runtime_invalid_composition"; + +const MESSAGES: Record = { + world_runtime_denied: "World runtime request denied.", + world_runtime_invalid_composition: "World runtime construction failed.", +}; + +/** Public, deliberately non-diagnostic failure surface for the B21 boundary. */ +export class WorldRuntimeError extends Error { + public readonly code: WorldRuntimeErrorCode; + public constructor(code: WorldRuntimeErrorCode) { + super(MESSAGES[code]); this.name = "WorldRuntimeError"; this.code = code; + } +} + +export interface WorldReadIdentity { + readonly run_id: string; readonly world_id: string; readonly world_instance_id: string; + readonly manifest_digest: string; readonly state_version: number; +} +export interface WorldReadLedgerRecord { + readonly sequence: number; readonly operation: WorldReadOperation; readonly principal: string; + readonly decision_id?: string; readonly state_version?: number; readonly result: "allowed" | "denied"; + readonly identity?: WorldReadIdentity; +} +export interface WorldReadLedgerPage { readonly records: readonly WorldReadLedgerRecord[]; readonly next_after: number; } +interface WorldReadLedgerAppend extends Omit {} +export interface WorldReadLedger { read(principal: string, request: unknown): WorldReadLedgerPage; } +export interface WorldReadLedgerAuthority { + append(input: WorldReadLedgerAppend): void; + read(principal: string, request: unknown): WorldReadLedgerPage; + reservePrincipals(principals: unknown): void; + /** @internal Host-only checkpoint seam; never present on the public handle. */ + snapshot(): WorldReadLedgerSnapshot; + /** @internal Host-only checkpoint seam; pristine targets only. */ + restore(input: unknown): void; +} +export interface WorldReadLedgerOptions { readonly maxEntriesPerPrincipal?: number; readonly maxPrincipals?: number; } + +const DEFAULT_LIMIT = 50; +const MAX_LIMIT = 100; +const DEFAULT_RETAINED_ENTRIES = 256; +const DEFAULT_PRINCIPALS = 256; +const MAX_RETAINED_ENTRIES = 10_000; +const MAX_PRINCIPALS = 4_096; +const MAX_TEXT = 256; +const DECISION_ID = /^decision-[0-9]{12}$/u; +const SHA256 = /^sha256:[a-f0-9]{64}$/u; +const issuedLedgers = new WeakMap(); + +type ParsedRequest = { readonly after: number; readonly limit: number; readonly operations?: readonly WorldReadOperation[] }; +type StoredRecord = Readonly; + +const denied = (): never => { throw new WorldRuntimeError("world_runtime_denied"); }; +const invalid = (): never => { throw new WorldRuntimeError("world_runtime_invalid_composition"); }; +const isProxy = (value: unknown): boolean => value !== null && typeof value === "object" && types.isProxy(value as object); +const safeInteger = (value: unknown, minimum: number, maximum = Number.MAX_SAFE_INTEGER): value is number => + typeof value === "number" && Number.isSafeInteger(value) && value >= minimum && value <= maximum; +const binding = (value: unknown): value is string => + typeof value === "string" && value.length > 0 && value.length <= MAX_TEXT && value === value.trim(); +const object = (value: unknown, allowed: readonly string[], exact = false): Record | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value) || isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) return undefined; + const keys = Reflect.ownKeys(value); + if ((exact && keys.length !== allowed.length) || keys.some((key) => typeof key !== "string" || !allowed.includes(key))) return undefined; + const output: Record = Object.create(null); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) return undefined; + output[key as string] = descriptor.value; + } + return output; +}; +const immutable = (value: T): T => Object.freeze(value); +const cloneIdentity = (identity: WorldReadIdentity): WorldReadIdentity => immutable({ ...identity }); +const cloneRecord = (record: WorldReadLedgerRecord): StoredRecord => immutable({ + ...record, ...(record.identity === undefined ? {} : { identity: cloneIdentity(record.identity) }), +}); +const principalReservation = (value: unknown): readonly string[] | undefined => { + if (!Array.isArray(value) || isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const length = Object.getOwnPropertyDescriptor(value, "length"); + if (!length || !("value" in length) || !safeInteger(length.value, 1, MAX_PRINCIPALS) + || Reflect.ownKeys(value).length !== length.value + 1) return undefined; + const principals: string[] = []; + for (let index = 0; index < length.value; index += 1) { + const entry = Object.getOwnPropertyDescriptor(value, String(index)); + if (!entry?.enumerable || !("value" in entry) || !binding(entry.value)) return undefined; + principals.push(entry.value); + } + return new Set(principals).size === principals.length ? principals : undefined; +}; + +export const parseWorldReadIdentity = (value: unknown): WorldReadIdentity | undefined => { + const input = object(value, ["run_id", "world_id", "world_instance_id", "manifest_digest", "state_version"], true); + if (input === undefined || !binding(input.run_id) || !binding(input.world_id) || !binding(input.world_instance_id) + || typeof input.manifest_digest !== "string" || !SHA256.test(input.manifest_digest) || !safeInteger(input.state_version, 0)) return undefined; + return cloneIdentity(input as unknown as WorldReadIdentity); +}; +export const parseWorldReadLedgerRecord = (value: unknown): Omit | undefined => { + const input = object(value, ["operation", "principal", "decision_id", "state_version", "result", "identity"]); + if (input === undefined || !WORLD_READ_OPERATIONS.includes(input.operation as WorldReadOperation) || !binding(input.principal) + || (input.result !== "allowed" && input.result !== "denied")) return undefined; + if (input.result === "denied" && (input.decision_id !== undefined || input.state_version !== undefined || input.identity !== undefined)) return undefined; + const identity = input.identity === undefined ? undefined : parseWorldReadIdentity(input.identity); + if (input.result === "allowed" && (typeof input.decision_id !== "string" || !DECISION_ID.test(input.decision_id) + || !safeInteger(input.state_version, 0) || identity === undefined || identity.state_version !== input.state_version)) return undefined; + return immutable({ operation: input.operation as WorldReadOperation, principal: input.principal, result: input.result, + ...(input.result === "denied" ? {} : { decision_id: input.decision_id as string, state_version: input.state_version as number, identity: identity! }), + }); +}; + +export const parseWorldReadLedgerRequest = (input: unknown): ParsedRequest => { + if (input === undefined) return immutable({ after: 0, limit: DEFAULT_LIMIT }); + const value = object(input, ["after", "limit", "operations"]); + if (value === undefined) return denied(); + const after = value.after === undefined ? 0 : value.after; + const limit = value.limit === undefined ? DEFAULT_LIMIT : value.limit; + if (!safeInteger(after, 0) || !safeInteger(limit, 1, MAX_LIMIT)) return denied(); + if (value.operations === undefined) return immutable({ after, limit }); + const array = value.operations; + if (!Array.isArray(array) || isProxy(array) || Object.getPrototypeOf(array) !== Array.prototype) return denied(); + const length = Object.getOwnPropertyDescriptor(array, "length"); + if (!length || !("value" in length) || !safeInteger(length.value, 1, WORLD_READ_OPERATIONS.length) || Reflect.ownKeys(array).length !== length.value + 1) return denied(); + const operations: WorldReadOperation[] = []; + for (let index = 0; index < length.value; index += 1) { + const entry = Object.getOwnPropertyDescriptor(array, String(index)); + if (!entry?.enumerable || !("value" in entry) || !WORLD_READ_OPERATIONS.includes(entry.value as WorldReadOperation)) return denied(); + operations.push(entry.value as WorldReadOperation); + } + if (new Set(operations).size !== operations.length) return denied(); + return immutable({ after, limit, operations: immutable(operations) }); +}; + +/** @internal Exact local-module authority check used by world composition. */ +export const readWorldReadLedger = (value: unknown): WorldReadLedgerAuthority | undefined => + value !== null && typeof value === "object" ? issuedLedgers.get(value) : undefined; + +export const createWorldReadLedger = (options: unknown = {}): WorldReadLedger => { + const parsed = object(options, ["maxEntriesPerPrincipal", "maxPrincipals"]); + const maximum = parsed?.maxEntriesPerPrincipal === undefined ? DEFAULT_RETAINED_ENTRIES : parsed.maxEntriesPerPrincipal; + const principals = parsed?.maxPrincipals === undefined ? DEFAULT_PRINCIPALS : parsed.maxPrincipals; + if (parsed === undefined || !safeInteger(maximum, 1, MAX_RETAINED_ENTRIES) || !safeInteger(principals, 1, MAX_PRINCIPALS)) return invalid(); + const entries = new Map(); + const sequences = new Map(); + let restored = false; + const append = (input: WorldReadLedgerAppend): void => { + const record = parseWorldReadLedgerRecord(input); + if (record === undefined) return invalid(); + const known = entries.get(record.principal); + if (restored && known === undefined) return invalid(); + if (known === undefined && entries.size >= principals) return invalid(); + const previous = sequences.get(record.principal) ?? 0; + if (previous >= Number.MAX_SAFE_INTEGER) return invalid(); + const sequence = previous + 1; + const stored = cloneRecord({ ...record, sequence }); + const retained = known === undefined ? [] : [...known]; + retained.push(stored); + if (retained.length > maximum) retained.splice(0, retained.length - maximum); + entries.set(record.principal, retained); + sequences.set(record.principal, sequence); + }; + const reservePrincipals = (input: unknown): void => { + const reserved = principalReservation(input); + if (reserved === undefined || entries.size > reserved.length + || [...entries.keys()].some((principal) => !reserved.includes(principal)) + || reserved.length > principals + || (restored && (entries.size !== reserved.length || reserved.some((principal) => !entries.has(principal))))) return invalid(); + for (const principal of reserved) if (!entries.has(principal)) entries.set(principal, []); + }; + const snapshot = (): WorldReadLedgerSnapshot => cloneWorldReadLedgerSnapshot({ + version: "simfile.world-read-ledger.v1", + max_entries_per_principal: maximum, + max_principals: principals, + lanes: [...entries.keys()].sort().map((principal) => { + const records = entries.get(principal)!; + const last_sequence = sequences.get(principal) ?? 0; + return { principal, last_sequence, evicted_through: records[0]?.sequence === undefined ? last_sequence : records[0].sequence - 1, records }; + }), + }); + const restore = (input: unknown): void => { + if (restored || sequences.size !== 0 || [...entries.values()].some((records) => records.length !== 0)) return invalid(); + const parsedSnapshot = parseWorldReadLedgerSnapshot(input); + if (parsedSnapshot === undefined || parsedSnapshot.max_entries_per_principal !== maximum || parsedSnapshot.max_principals !== principals) return invalid(); + const reserved = [...entries.keys()]; + if (reserved.length > 0 && (reserved.length !== parsedSnapshot.lanes.length || reserved.some((principal) => !parsedSnapshot.lanes.some((lane) => lane.principal === principal)))) return invalid(); + const nextEntries = new Map(); const nextSequences = new Map(); + for (const lane of parsedSnapshot.lanes) { nextEntries.set(lane.principal, [...lane.records].map(cloneRecord)); nextSequences.set(lane.principal, lane.last_sequence); } + entries.clear(); sequences.clear(); + for (const [principal, records] of nextEntries) entries.set(principal, records); + for (const [principal, value] of nextSequences) sequences.set(principal, value); + restored = true; + }; + const read = (principal: string, request: unknown): WorldReadLedgerPage => { + if (!binding(principal)) return denied(); + const parsedRequest = parseWorldReadLedgerRequest(request); + const allowed = parsedRequest.operations === undefined ? undefined : new Set(parsedRequest.operations); + const records = (entries.get(principal) ?? []).filter((record) => record.sequence > parsedRequest.after && (allowed === undefined || allowed.has(record.operation))) + .slice(0, parsedRequest.limit).map(cloneRecord); + return immutable({ records: immutable(records), next_after: records.at(-1)?.sequence ?? parsedRequest.after }); + }; + const authority = Object.freeze({ append, read, reservePrincipals, snapshot, restore }); + const ledger: WorldReadLedger = Object.freeze({ read }); + issuedLedgers.set(ledger, authority); + return ledger; +}; diff --git a/src/world/observe.test.ts b/src/world/observe.test.ts new file mode 100644 index 0000000..ce99e02 --- /dev/null +++ b/src/world/observe.test.ts @@ -0,0 +1,220 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { WorldRuntimeError } from "./ledger.js"; +import type { DynamicsSession } from "../dynamics/session.js"; +import { runtimeFixture, runtimeFixtureWithHooks } from "./runtime.test-helper.js"; + +const denied = (call: () => unknown): void => { + assert.throws(call, (error: unknown) => error instanceof WorldRuntimeError + && error.code === "world_runtime_denied"); +}; + +const projectionAction = (actId: string) => ({ + act_id: actId, action: "wait", actor: "object:red", at_tick: 0, input: {}, origin: "test", + principal_id: "principal-red", target: "object:red", +}); + +test("observes only the authenticated granted sense in a frozen public envelope", () => { + const fixture = runtimeFixture(); + const decisions = fixture.decisionRegistry.snapshot(); + const result = fixture.runtime.observe({ principal: "principal-red", decisionToken: fixture.red.token }, { + sense: "world://pitch/sense/vision", + }); + assert.deepEqual(result.identity, { + run_id: "run-1", world_id: "pitch", world_instance_id: "instance-1", + manifest_digest: result.identity.manifest_digest, state_version: 0, + }); + assert.equal(result.sense, "world://pitch/sense/vision"); + assert.equal(result.observer, "world://pitch/entity/red"); + assert.deepEqual(result.observation, { + channels: [{ components: { x: 1 }, sense_address: "world://pitch/sense/vision", subject_address: "world://pitch/entity/red", unit: "meters" }], + }); + assert.ok(Object.isFrozen(result)); + assert.ok(Object.isFrozen(result.observation.channels[0]!.components)); + assert.throws(() => (result.observation.channels as unknown as unknown[]).push({}), TypeError); + assert.equal(JSON.stringify(result).includes("sense:state"), false); + assert.equal(fixture.dynamicsCalls(), 1); + assert.deepEqual(fixture.decisionRegistry.snapshot(), decisions); +}); + +test("passes only frozen scoped mechanics to a projection that derives public channels", () => { + let providerInput: unknown; + let projectionInput: unknown; + const fixture = runtimeFixtureWithHooks({ + observe: (input) => { + providerInput = input; + return { channels: [{ components: { x: 7 }, sense_address: "sense:state", subject_address: "object:red", unit: "meters" }] }; + }, + project: (input) => { + projectionInput = input; + return { channels: input.observation.channels.map((channel) => ({ + components: channel.components, sense_address: "sense:vision", subject_address: "entity:red", unit: channel.unit, + })) }; + }, + }); + const result = fixture.runtime!.observe({ principal: "principal-red", decisionToken: fixture.red.token }, { sense: "world://pitch/sense/vision" }); + assert.deepEqual(providerInput, { sense_addresses: ["sense:state"], sim_time: 0, tick: 0 }); + assert.deepEqual(Reflect.ownKeys(providerInput as object), ["sense_addresses", "sim_time", "tick"]); + assert.ok(Object.isFrozen(providerInput)); + assert.ok(Object.isFrozen((providerInput as { sense_addresses: unknown }).sense_addresses)); + assert.deepEqual(JSON.parse(JSON.stringify(projectionInput)), { holder: "entity:red", observation: { channels: [{ components: { x: 7 }, sense_address: "sense:state", subject_address: "object:red", unit: "meters" }] } }); + assert.ok(Object.isFrozen(projectionInput)); + assert.deepEqual(result.observation.channels[0]!.components, { x: 7 }); + assert.equal(JSON.stringify(result).includes("sense:state"), false); +}); + +test("denies hostile providers and leaves their complete session checkpoint unchanged", () => { + const hostile = [ + () => { throw new Error("provider"); }, + () => Promise.resolve({ channels: [] }), + (_input: unknown, state: Record) => { state.changed = true; return { channels: [] }; }, + () => ({}), + () => ({ channels: [{ components: { x: 1 }, sense_address: "sense:secret", subject_address: "object:red" }] }), + ]; + for (const observe of hostile) { + const fixture = runtimeFixtureWithHooks({ observe }); + const before = fixture.dynamics.snapshot(); + denied(() => fixture.runtime!.observe({ principal: "principal-red", decisionToken: fixture.red.token }, { sense: "world://pitch/sense/vision" })); + assert.deepEqual(fixture.dynamics.snapshot(), before); + assert.deepEqual(fixture.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["observe", "denied"]]); + } +}); + +test("denies and exactly restores complete state after every hostile projection result", () => { + const projectors = [ + (dynamics: DynamicsSession, state: Record) => { state.changed = "throw"; dynamics.queueAction(projectionAction("projection-throw")); throw new Error("projection"); }, + (dynamics: DynamicsSession, state: Record) => { state.changed = "promise"; dynamics.queueAction(projectionAction("projection-promise")); return Promise.resolve({ channels: [] }); }, + (dynamics: DynamicsSession, state: Record) => { state.changed = "malformed"; dynamics.queueAction(projectionAction("projection-malformed")); return {}; }, + (dynamics: DynamicsSession, state: Record) => { state.changed = "valid"; dynamics.queueAction(projectionAction("projection-valid")); return { channels: [{ components: { x: 1 }, sense_address: "sense:vision", subject_address: "entity:red", unit: "meters" }] }; }, + ]; + for (const mutateAndResult of projectors) { + let capturedState: Record | undefined; + const fixture = runtimeFixtureWithHooks({ + observe: (_input, state) => { + capturedState = state; + return { channels: [{ components: { x: 1 }, sense_address: "sense:state", subject_address: "object:red" }] }; + }, + project: (_input, dynamics) => mutateAndResult(dynamics, capturedState!), + }); + const before = fixture.dynamics.snapshot(); + denied(() => fixture.runtime!.observe({ principal: "principal-red", decisionToken: fixture.red.token }, { sense: "world://pitch/sense/vision" })); + assert.deepEqual(fixture.dynamics.snapshot(), before); + assert.deepEqual(fixture.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["observe", "denied"]]); + } +}); + +test("keeps caught and uncaught projection reentry to one nested denial and one outer record", () => { + let caught = runtimeFixtureWithHooks({ + project: (input) => { + try { caught.runtime!.observe({ principal: "principal-red", decisionToken: caught.red.token }, { sense: "world://pitch/sense/vision" }); } catch { /* Expected nested denial. */ } + return { channels: input.observation.channels.map((channel) => ({ components: channel.components, sense_address: "sense:vision", subject_address: "entity:red" })) }; + }, + }); + caught.runtime!.observe({ principal: "principal-red", decisionToken: caught.red.token }, { sense: "world://pitch/sense/vision" }); + assert.deepEqual(caught.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["observe", "denied"], ["observe", "allowed"]]); + assert.equal(caught.dynamicsCalls(), 1); + + let uncaught = runtimeFixtureWithHooks({ + project: () => uncaught.runtime!.observe({ principal: "principal-red", decisionToken: uncaught.red.token }, { sense: "world://pitch/sense/vision" }), + }); + denied(() => uncaught.runtime!.observe({ principal: "principal-red", decisionToken: uncaught.red.token }, { sense: "world://pitch/sense/vision" })); + assert.deepEqual(uncaught.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["observe", "denied"], ["observe", "denied"]]); + assert.equal(uncaught.dynamicsCalls(), 1); +}); + +test("keeps caught and uncaught provider reentry before a second provider call", () => { + let caughtCalls = 0; + let caught: ReturnType; + caught = runtimeFixtureWithHooks({ + observe: (input) => { + caughtCalls += 1; + try { caught.runtime!.observe({ principal: "principal-red", decisionToken: caught.red.token }, { sense: "world://pitch/sense/vision" }); } catch { /* Expected nested denial. */ } + return { channels: [{ components: { x: 3 }, sense_address: "sense:state", subject_address: "object:red" }] }; + }, + }); + caught.runtime!.observe({ principal: "principal-red", decisionToken: caught.red.token }, { sense: "world://pitch/sense/vision" }); + assert.equal(caughtCalls, 1); + assert.deepEqual(caught.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["observe", "denied"], ["observe", "allowed"]]); + + let uncaughtCalls = 0; + let uncaught: ReturnType; + uncaught = runtimeFixtureWithHooks({ + observe: () => { + uncaughtCalls += 1; + return uncaught.runtime!.observe({ principal: "principal-red", decisionToken: uncaught.red.token }, { sense: "world://pitch/sense/vision" }); + }, + }); + denied(() => uncaught.runtime!.observe({ principal: "principal-red", decisionToken: uncaught.red.token }, { sense: "world://pitch/sense/vision" })); + assert.equal(uncaughtCalls, 1); + assert.deepEqual(uncaught.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["observe", "denied"], ["observe", "denied"]]); +}); + +test("keeps repeated valid observes pure and isolated", () => { + const fixture = runtimeFixture(); + const beforeDynamics = fixture.dynamics.snapshot(); + const beforeDecisions = fixture.decisionRegistry.snapshot(); + const first = fixture.runtime.observe({ principal: "principal-red", decisionToken: fixture.red.token }, { sense: "world://pitch/sense/vision" }); + const second = fixture.runtime.observe({ principal: "principal-red", decisionToken: fixture.red.token }, { sense: "world://pitch/sense/vision" }); + assert.deepEqual(first, second); + assert.notEqual(first, second); + assert.equal(fixture.dynamicsCalls(), 2); + assert.deepEqual(fixture.dynamics.snapshot(), beforeDynamics); + assert.deepEqual(fixture.decisionRegistry.snapshot(), beforeDecisions); + assert.deepEqual(fixture.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["observe", "allowed"], ["observe", "allowed"]]); +}); + +test("denies observe for every terminal decision admission before callbacks", () => { + const cases = [ + { name: "wrong principal/token", prepare: (_fixture: ReturnType) => ({ principal: "principal-red", token: _fixture.blue.token }) }, + { name: "expired", prepare: (fixture: ReturnType) => { fixture.dynamics.step(); fixture.dynamics.step(); fixture.dynamics.step(); return { principal: "principal-red", token: fixture.red.token }; } }, + { name: "consumed", prepare: (fixture: ReturnType) => { fixture.decisionRegistry.consumeForAct({ principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: fixture.red.token, atTick: 0 }); return { principal: "principal-red", token: fixture.red.token }; } }, + { name: "admissions closed", prepare: (fixture: ReturnType) => { fixture.decisionRegistry.consumeForAct({ principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: fixture.red.token, atTick: 0 }); fixture.decisionRegistry.consumeForAct({ principal: "principal-blue", runId: "run-1", worldInstanceId: "instance-1", token: fixture.blue.token, atTick: 0 }); fixture.decisionRegistry.beginCutoff(0); fixture.decisionRegistry.closeAdmissions(0); assert.equal(fixture.decisionRegistry.inspect().phase, "admissions_closed"); return { principal: "principal-red", token: fixture.red.token }; } }, + { name: "finalized", prepare: (fixture: ReturnType) => { fixture.decisionRegistry.consumeForAct({ principal: "principal-red", runId: "run-1", worldInstanceId: "instance-1", token: fixture.red.token, atTick: 0 }); fixture.decisionRegistry.consumeForAct({ principal: "principal-blue", runId: "run-1", worldInstanceId: "instance-1", token: fixture.blue.token, atTick: 0 }); fixture.decisionRegistry.beginCutoff(0); fixture.decisionRegistry.closeAdmissions(0); fixture.decisionRegistry.finalize(0); assert.equal(fixture.decisionRegistry.inspect().phase, "finalized"); return { principal: "principal-red", token: fixture.red.token }; } }, + ] as const; + for (const admissionCase of cases) { + let providerCalls = 0; + let projectionCalls = 0; + const fixture = runtimeFixtureWithHooks({ + observe: () => { providerCalls += 1; return { channels: [{ components: { x: 1 }, sense_address: "sense:state", subject_address: "object:red" }] }; }, + project: (input, dynamics) => { projectionCalls += 1; return { channels: input.observation.channels }; }, + }); + const context = admissionCase.prepare(fixture); + const beforeDynamics = fixture.dynamics.snapshot(); + const beforeDecisions = fixture.decisionRegistry.snapshot(); + denied(() => fixture.runtime!.observe({ principal: context.principal, decisionToken: context.token }, { sense: "world://pitch/sense/vision" })); + assert.equal(providerCalls, 0, admissionCase.name); + assert.equal(projectionCalls, 0, admissionCase.name); + assert.deepEqual(fixture.dynamics.snapshot(), beforeDynamics, admissionCase.name); + assert.deepEqual(fixture.decisionRegistry.snapshot(), beforeDecisions, admissionCase.name); + assert.deepEqual(fixture.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), [["observe", "denied"]], admissionCase.name); + } +}); + +test("denies malformed, foreign, local, mechanics, and ungranted senses without disclosure", () => { + const fixture = runtimeFixture(); + let accessed = 0; + const accessor: Record = {}; + Object.defineProperty(accessor, "sense", { enumerable: true, get: () => { accessed += 1; return "world://pitch/sense/vision"; } }); + const requests: unknown[] = [ + undefined, null, {}, { sense: "sense:vision" }, { sense: "sense:state" }, + { sense: "world://other/sense/vision" }, { sense: "world://pitch/sense/missing" }, + { sense: "world://pitch/sense/blue-view" }, accessor, new Proxy({ sense: "world://pitch/sense/vision" }, {}), + ]; + for (const request of requests) denied(() => fixture.runtime.observe({ principal: "principal-red", decisionToken: fixture.red.token }, request)); + denied(() => fixture.runtime.observe({ principal: "principal-blue", decisionToken: fixture.blue.token }, { sense: "world://pitch/sense/vision" })); + assert.equal(accessed, 0); + assert.equal(fixture.dynamicsCalls(), 0); + assert.deepEqual(fixture.readLedger.read("principal-red", {}).records.map((record) => [record.operation, record.result]), + requests.map(() => ["observe", "denied"])); +}); + +test("records allowed observations and accepts observe ledger filters", () => { + const fixture = runtimeFixture(); + fixture.runtime.observe({ principal: "principal-red", decisionToken: fixture.red.token }, { sense: "world://pitch/sense/vision" }); + const page = fixture.runtime.ledger({ principal: "principal-red", decisionToken: fixture.red.token }, { operations: ["observe"] }); + assert.equal(page.records.length, 1); + assert.equal(page.records[0]!.operation, "observe"); + assert.equal(page.records[0]!.result, "allowed"); + assert.equal(page.records[0]!.identity!.state_version, 0); +}); diff --git a/src/world/observe.ts b/src/world/observe.ts new file mode 100644 index 0000000..c72a897 --- /dev/null +++ b/src/world/observe.ts @@ -0,0 +1,139 @@ +import { types } from "node:util"; + +import type { DynamicsSession } from "../dynamics/session.js"; +import { sameDynamicsSessionSnapshot } from "../dynamics/sameDynamicsSessionSnapshot.js"; +import type { WorldSurfaceRegistry } from "../world-surface/index.js"; +import type { ReadonlyWorldSurfaceObservation } from "../world-surface/types.js"; +import { + resolveWorldAddress, + type CanonicalWorldAddress, + type LocalResourceReference, +} from "./addresses.js"; +import type { CapabilityManifest } from "./capabilityManifest.js"; +import type { WorldRuntimeIdentity } from "./runtime.js"; + +export interface WorldObserveRequest { + readonly sense: CanonicalWorldAddress; +} + +export interface WorldRuntimeObservation { + readonly identity: WorldRuntimeIdentity; + readonly sense: CanonicalWorldAddress; + readonly observer: CanonicalWorldAddress; + readonly observation: { + readonly channels: readonly { + readonly components: Readonly>; + readonly frame?: string; + readonly sense_address: CanonicalWorldAddress; + readonly subject_address: CanonicalWorldAddress; + readonly unit?: string; + }[]; + }; +} + +type ObserveDependencies = Readonly<{ dynamics: DynamicsSession; surfaceRegistry: WorldSurfaceRegistry }>; +type ScopedWorldObservation = Readonly<{ + holder: LocalResourceReference; + observation: ReadonlyWorldSurfaceObservation; +}>; + +const isProxy = (value: unknown): boolean => value !== null && typeof value === "object" && types.isProxy(value as object); + +const requestSense = (value: unknown): CanonicalWorldAddress | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value) || isProxy(value) + || Object.getPrototypeOf(value) !== Object.prototype) return undefined; + const keys = Reflect.ownKeys(value); + const descriptor = Object.getOwnPropertyDescriptor(value, "sense"); + if (keys.length !== 1 || keys[0] !== "sense" || !descriptor?.enumerable || !("value" in descriptor) + || typeof descriptor.value !== "string") return undefined; + return descriptor.value as CanonicalWorldAddress; +}; + +const canonical = (worldId: string, local: string): CanonicalWorldAddress => + resolveWorldAddress({ id: worldId as never }, local as LocalResourceReference); + +const freezeChannel = ( + worldId: string, + value: { readonly components: Readonly>; readonly frame?: string; readonly sense_address: string; readonly subject_address: string; readonly unit?: string }, +): WorldRuntimeObservation["observation"]["channels"][number] => { + const components = Object.freeze(Object.fromEntries(Object.entries(value.components))); + return Object.freeze({ + components, + ...(value.frame === undefined ? {} : { frame: value.frame }), + sense_address: canonical(worldId, value.sense_address), + subject_address: canonical(worldId, value.subject_address), + ...(value.unit === undefined ? {} : { unit: value.unit }), + }); +}; + +const projectionDidNotMutateSession = ( + dynamics: DynamicsSession, + checkpoint: ReturnType, +): boolean => { + try { + const current = dynamics.snapshot(); + if (sameDynamicsSessionSnapshot(current, checkpoint)) return true; + } catch { + // A failed inspection is treated as a possible mutation and recovered below. + } + try { dynamics.restore(checkpoint); } catch { /* Denial remains authoritative. */ } + return false; +}; + +/** @internal Shared B66/B67 scoped local observation; not part of the public barrel. */ +export const observeScopedWorldRuntime = ( + dependencies: ObserveDependencies, + manifest: CapabilityManifest, + identity: WorldRuntimeIdentity, + requested: CanonicalWorldAddress, +): ScopedWorldObservation => { + if (!manifest.senses.some((sense) => sense.address === requested)) throw new Error("denied"); + const worldId = manifest.world.id; + const sense = dependencies.surfaceRegistry.senses.find((entry) => canonical(worldId, entry.address) === requested); + const holder = dependencies.surfaceRegistry.entities.find((entry) => canonical(worldId, entry.address) === manifest.holder.entity); + if (sense === undefined || holder === undefined) throw new Error("denied"); + if (dependencies.dynamics.nextTick !== identity.state_version) throw new Error("denied"); + let providerCheckpoint: ReturnType | undefined; + let mechanics: ReturnType; + try { + providerCheckpoint = dependencies.dynamics.snapshot(); + mechanics = dependencies.dynamics.observe({ observer: holder.dynamics_address, + principal_id: manifest.holder.principal, sense_addresses: [...sense.dynamics_senses] }); + } catch { + if (providerCheckpoint !== undefined) projectionDidNotMutateSession(dependencies.dynamics, providerCheckpoint); + throw new Error("denied"); + } + if (!projectionDidNotMutateSession(dependencies.dynamics, providerCheckpoint) + || mechanics.tick !== identity.state_version || mechanics.observer !== holder.dynamics_address + || mechanics.principal_id !== manifest.holder.principal || dependencies.dynamics.nextTick !== identity.state_version) throw new Error("denied"); + let checkpoint: ReturnType; + try { checkpoint = dependencies.dynamics.snapshot(); } catch { throw new Error("denied"); } + let projected: ReturnType; + try { + projected = dependencies.surfaceRegistry.projectSense(sense.address, { + holder: holder.address, + observation: { channels: mechanics.channels }, + }); + } catch { + projectionDidNotMutateSession(dependencies.dynamics, checkpoint); + throw new Error("denied"); + } + if (!projectionDidNotMutateSession(dependencies.dynamics, checkpoint) + || dependencies.dynamics.nextTick !== identity.state_version) throw new Error("denied"); + return Object.freeze({ holder: holder.address, observation: projected }); +}; + +/** @internal B66 runtime orchestration; public callers use WorldRuntime.observe. */ +export const observeWorldRuntime = ( + dependencies: ObserveDependencies, + manifest: CapabilityManifest, + identity: WorldRuntimeIdentity, + value: unknown, +): WorldRuntimeObservation => { + const requested = requestSense(value); + if (requested === undefined) throw new Error("denied"); + const local = observeScopedWorldRuntime(dependencies, manifest, identity, requested); + const channels = Object.freeze(local.observation.channels.map((channel) => freezeChannel(manifest.world.id, channel))); + return Object.freeze({ identity: Object.freeze({ ...identity }), sense: requested, + observer: manifest.holder.entity, observation: Object.freeze({ channels }) }); +}; diff --git a/src/world/readLedgerSnapshot.test.ts b/src/world/readLedgerSnapshot.test.ts new file mode 100644 index 0000000..309cd5f --- /dev/null +++ b/src/world/readLedgerSnapshot.test.ts @@ -0,0 +1,68 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createWorldReadLedger, readWorldReadLedger, WorldRuntimeError } from "./ledger.js"; + +const writer = (ledger: ReturnType) => readWorldReadLedger(ledger)!; +const allowed = (principal: string) => ({ operation: "ledger" as const, principal, result: "allowed" as const, decision_id: "decision-000000000001", state_version: 2, + identity: { run_id: "run", world_id: "world", world_instance_id: "instance", manifest_digest: `sha256:${"a".repeat(64)}`, state_version: 2 } }); + +test("privately snapshots empty, reserved, denied, allowed, and evicted lanes", () => { + const source = createWorldReadLedger({ maxEntriesPerPrincipal: 2, maxPrincipals: 3 }); const authority = writer(source); + authority.reservePrincipals(["blue", "red"]); authority.append({ operation: "status", principal: "red", result: "denied" }); authority.append(allowed("red")); authority.append({ operation: "observe", principal: "red", result: "denied" }); + const snapshot = authority.snapshot(); + assert.deepEqual(snapshot.lanes.map((lane) => [lane.principal, lane.last_sequence, lane.evicted_through, lane.records.length]), [["blue", 0, 0, 0], ["red", 3, 1, 2]]); + assert(Object.isFrozen(snapshot)); assert(Object.isFrozen(snapshot.lanes)); assert(Object.isFrozen(snapshot.lanes[1]!.records)); + const target = createWorldReadLedger({ maxEntriesPerPrincipal: 2, maxPrincipals: 3 }); writer(target).reservePrincipals(["red", "blue"]); writer(target).restore(snapshot); + assert.deepEqual(writer(target).snapshot(), snapshot); assert.deepEqual(target.read("red", { after: 0 }).records.map((record) => record.sequence), [2, 3]); + writer(target).append({ operation: "status", principal: "red", result: "denied" }); assert.deepEqual(target.read("red", { after: 0 }).records.map((record) => record.sequence), [3, 4]); + assert.throws(() => writer(target).restore(snapshot), WorldRuntimeError); +}); + +test("restored exact lanes reject foreign appends atomically and preserve continuity", () => { + const source = createWorldReadLedger({ maxEntriesPerPrincipal: 2, maxPrincipals: 2 }); + const sourceWriter = writer(source); sourceWriter.reservePrincipals(["red"]); + sourceWriter.append({ operation: "status", principal: "red", result: "denied" }); + sourceWriter.append({ operation: "ledger", principal: "red", result: "denied" }); + const snapshot = sourceWriter.snapshot(); const target = createWorldReadLedger({ maxEntriesPerPrincipal: 2, maxPrincipals: 2 }); + const targetWriter = writer(target); targetWriter.restore(snapshot); const before = targetWriter.snapshot(); + assert.throws(() => targetWriter.append({ operation: "status", principal: "blue", result: "denied" }), WorldRuntimeError); + assert.deepEqual(targetWriter.snapshot(), before); + assert.deepEqual(target.read("blue", { after: 0 }).records, []); + assert.deepEqual(target.read("red", { after: 0 }).records.map((record) => record.sequence), [1, 2]); + targetWriter.append({ operation: "observe", principal: "red", result: "denied" }); + assert.deepEqual(target.read("red", { after: 1, operations: ["observe"] }), { + records: [{ operation: "observe", principal: "red", result: "denied", sequence: 3 }], next_after: 3, + }); + assert.deepEqual(target.read("red", { after: 99 }), { records: [], next_after: 99 }); + assert.throws(() => targetWriter.reservePrincipals(["red", "blue"]), WorldRuntimeError); + const preReserved = createWorldReadLedger({ maxPrincipals: 2 }); const preReservedWriter = writer(preReserved); + preReservedWriter.reservePrincipals(["red", "blue"]); const preReservedBefore = preReservedWriter.snapshot(); + assert.throws(() => preReservedWriter.restore(snapshot), WorldRuntimeError); + assert.deepEqual(preReservedWriter.snapshot(), preReservedBefore); +}); + +test("restores the exact 10,000-record retained suffix and numeric cursor frontier", () => { + const source = createWorldReadLedger({ maxEntriesPerPrincipal: 10_000, maxPrincipals: 1 }); const sourceWriter = writer(source); + sourceWriter.reservePrincipals(["red"]); + for (let sequence = 1; sequence <= 10_000; sequence += 1) sourceWriter.append({ operation: sequence % 2 === 0 ? "ledger" : "status", principal: "red", result: "denied" }); + const snapshot = sourceWriter.snapshot(); assert.equal(snapshot.lanes[0]!.records.length, 10_000); + const target = createWorldReadLedger({ maxEntriesPerPrincipal: 10_000, maxPrincipals: 1 }); const targetWriter = writer(target); + targetWriter.restore(snapshot); + assert.deepEqual(target.read("red", { after: 9_998, limit: 5 }), source.read("red", { after: 9_998, limit: 5 })); + assert.deepEqual(target.read("red", { after: 10_000 }), { records: [], next_after: 10_000 }); + targetWriter.append({ operation: "observe", principal: "red", result: "denied" }); + const suffix = target.read("red", { after: 0, limit: 100 }); + assert.equal(suffix.records.length, 100); assert.equal(suffix.records[0]!.sequence, 2); assert.equal(suffix.records.at(-1)!.sequence, 101); + assert.equal(targetWriter.snapshot().lanes[0]!.evicted_through, 1); +}); + +test("restore is atomic, capacity-bound, and descriptor-safe", () => { + const source = createWorldReadLedger(); writer(source).reservePrincipals(["red"]); writer(source).append({ operation: "status", principal: "red", result: "denied" }); const snapshot = structuredClone(writer(source).snapshot()) as Record; + const target = createWorldReadLedger(); const targetWriter = writer(target); const before = targetWriter.snapshot(); + snapshot.max_principals = 1; assert.throws(() => targetWriter.restore(snapshot), WorldRuntimeError); assert.deepEqual(targetWriter.snapshot(), before); + const hostile = structuredClone(writer(source).snapshot()) as Record; let touched = false; + hostile.lanes = new Array(10_001); Object.defineProperty(hostile.lanes, "0", { enumerable: true, get: () => { touched = true; throw new Error("trap"); } }); + assert.throws(() => targetWriter.restore(hostile), WorldRuntimeError); assert.equal(touched, false); assert.deepEqual(targetWriter.snapshot(), before); + const mismatch = createWorldReadLedger({ maxEntriesPerPrincipal: 2 }); assert.throws(() => writer(mismatch).restore(writer(source).snapshot()), WorldRuntimeError); +}); diff --git a/src/world/readLedgerSnapshot.ts b/src/world/readLedgerSnapshot.ts new file mode 100644 index 0000000..d37b5c0 --- /dev/null +++ b/src/world/readLedgerSnapshot.ts @@ -0,0 +1,83 @@ +import { types } from "node:util"; + +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import { parseWorldReadLedgerRecord, type WorldReadLedgerRecord } from "./ledger.js"; + +export const WORLD_READ_LEDGER_SNAPSHOT_VERSION = "simfile.world-read-ledger.v1" as const; +export interface WorldReadLedgerSnapshotLane { readonly principal: string; readonly last_sequence: number; readonly evicted_through: number; readonly records: readonly WorldReadLedgerRecord[]; } +export interface WorldReadLedgerSnapshot { readonly version: typeof WORLD_READ_LEDGER_SNAPSHOT_VERSION; readonly max_entries_per_principal: number; readonly max_principals: number; readonly lanes: readonly WorldReadLedgerSnapshotLane[]; } + +const MAX_TEXT = 256; +const binding = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= MAX_TEXT && value === value.trim(); +const integer = (value: unknown, minimum: number, maximum = Number.MAX_SAFE_INTEGER): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= minimum && value <= maximum; +const frozen = (value: T): T => Object.freeze(value); +const proxy = (value: object): boolean => types.isProxy(value); +const dangerous = new Set(["__proto__", "constructor", "prototype"]); +const index = /^(?:0|[1-9][0-9]*)$/u; +/* Establish graph identity without running caller code; individual records are + * parsed separately so a legal retained history is not one generic JSON blob. */ +const scan = (value: unknown, seen: Set, depth = 0): void => { + if (depth > 24 || value === null || typeof value !== "object") return; + if (proxy(value as object) || seen.has(value as object)) throw new TypeError("hostile read ledger snapshot"); + seen.add(value as object); const array = Array.isArray(value); const prototype = Object.getPrototypeOf(value); + if ((array && prototype !== Array.prototype) || (!array && prototype !== Object.prototype && prototype !== null)) throw new TypeError("hostile read ledger snapshot"); + for (let current: object | null = value as object; current !== null; current = Object.getPrototypeOf(current)) if (proxy(current) || Object.getOwnPropertyDescriptor(current, "then") !== undefined) throw new TypeError("hostile read ledger snapshot"); + const keys = Reflect.ownKeys(value); + if (array) { const length = Object.getOwnPropertyDescriptor(value, "length"); if (!length || !("value" in length) || !integer(length.value, 0, DYNAMICS_LIMITS.retained_action_records) || keys.length !== length.value + 1 || keys.some((key) => typeof key !== "string" || (key !== "length" && (!index.test(key) || Number(key) >= length.value)))) throw new TypeError("hostile read ledger snapshot"); for (let offset = 0; offset < length.value; offset += 1) { const descriptor = Object.getOwnPropertyDescriptor(value, String(offset)); if (!descriptor?.enumerable || !("value" in descriptor)) throw new TypeError("hostile read ledger snapshot"); scan(descriptor.value, seen, depth + 1); } return; } + for (const key of keys) { if (typeof key !== "string" || dangerous.has(key)) throw new TypeError("hostile read ledger snapshot"); const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor?.enumerable || !("value" in descriptor)) throw new TypeError("hostile read ledger snapshot"); scan(descriptor.value, seen, depth + 1); } +}; + +const fields = (value: unknown, expected: readonly string[], nullPrototype = false): Record | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value) || proxy(value as object)) return undefined; + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && (!nullPrototype || prototype !== null)) return undefined; + const keys = Reflect.ownKeys(value); + if (keys.length !== expected.length || keys.some((key) => typeof key !== "string" || dangerous.has(key) || !expected.includes(key))) return undefined; + const result: Record = Object.create(null); + for (const key of expected) { const descriptor = Object.getOwnPropertyDescriptor(value, key); if (!descriptor?.enumerable || !("value" in descriptor)) return undefined; result[key] = descriptor.value; } + return result; +}; +/** Descriptor-only scan. Its limit is checked before looking at index zero. */ +const values = (value: unknown, limit: number): readonly unknown[] | undefined => { + if (!Array.isArray(value) || proxy(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const length = Object.getOwnPropertyDescriptor(value, "length"); + if (!length || !("value" in length) || !integer(length.value, 0, limit)) return undefined; + const keys = Reflect.ownKeys(value); + if (keys.length !== length.value + 1 || keys.some((key) => typeof key !== "string" || (key !== "length" && (!/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= length.value)))) return undefined; + const result: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); if (!descriptor?.enumerable || !("value" in descriptor)) return undefined; result.push(descriptor.value); } + return result; +}; +const clone = (record: WorldReadLedgerRecord): WorldReadLedgerRecord => frozen({ ...record, ...(record.identity === undefined ? {} : { identity: frozen({ ...record.identity }) }) }); +const parseRecord = (value: unknown, principal: string, sequence: number): WorldReadLedgerRecord | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value) || proxy(value as object)) return undefined; + const keys = Reflect.ownKeys(value); + if (keys.some((key) => typeof key !== "string")) return undefined; + const source = fields(value, keys as string[], true); + if (source === undefined || source.sequence !== sequence || source.principal !== principal) return undefined; + const { sequence: ignored, ...rest } = source; + const record = parseWorldReadLedgerRecord(rest); + return record === undefined ? undefined : clone({ ...record, sequence }); +}; +const parseLane = (value: unknown, capacity: number): WorldReadLedgerSnapshotLane | undefined => { + const source = fields(value, ["principal", "last_sequence", "evicted_through", "records"]); + if (source === undefined || !binding(source.principal) || !integer(source.last_sequence, 0) || !integer(source.evicted_through, 0, source.last_sequence)) return undefined; + const rawRecords = values(source.records, capacity); + if (rawRecords === undefined || rawRecords.length !== source.last_sequence - source.evicted_through) return undefined; + const records: WorldReadLedgerRecord[] = []; + for (let index = 0; index < rawRecords.length; index += 1) { const record = parseRecord(rawRecords[index], source.principal, source.evicted_through + index + 1); if (record === undefined) return undefined; records.push(record); } + return frozen({ principal: source.principal, last_sequence: source.last_sequence, evicted_through: source.evicted_through, records: frozen(records) }); +}; +const parse = (input: unknown): WorldReadLedgerSnapshot | undefined => { + scan(input, new Set()); + const source = fields(input, ["version", "max_entries_per_principal", "max_principals", "lanes"]); + if (source === undefined || source.version !== WORLD_READ_LEDGER_SNAPSHOT_VERSION || !integer(source.max_entries_per_principal, 1, DYNAMICS_LIMITS.retained_action_records) || !integer(source.max_principals, 1, 4096)) return undefined; + const rawLanes = values(source.lanes, source.max_principals); + if (rawLanes === undefined) return undefined; + const lanes: WorldReadLedgerSnapshotLane[] = []; + for (const rawLane of rawLanes) { const lane = parseLane(rawLane, source.max_entries_per_principal); if (lane === undefined || lanes.some((known) => known.principal === lane.principal)) return undefined; lanes.push(lane); } + lanes.sort((left, right) => left.principal < right.principal ? -1 : left.principal > right.principal ? 1 : 0); + return frozen({ version: WORLD_READ_LEDGER_SNAPSHOT_VERSION, max_entries_per_principal: source.max_entries_per_principal, max_principals: source.max_principals, lanes: frozen(lanes) }); +}; +export const parseWorldReadLedgerSnapshot = (input: unknown): WorldReadLedgerSnapshot | undefined => { try { return parse(input); } catch { return undefined; } }; +export const cloneWorldReadLedgerSnapshot = (input: WorldReadLedgerSnapshot): WorldReadLedgerSnapshot => { const parsed = parseWorldReadLedgerSnapshot(input); if (parsed === undefined) throw new TypeError("invalid world read ledger snapshot"); return parsed; }; diff --git a/src/world/requestLedger.test.ts b/src/world/requestLedger.test.ts new file mode 100644 index 0000000..be9740c --- /dev/null +++ b/src/world/requestLedger.test.ts @@ -0,0 +1,259 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { encodeWorldActEnvelope } from "./actEnvelope.js"; +import { + createWorldRequestLedger, + type WorldRequestAuthority, + type WorldRequestLedger, + type WorldRequestLedgerSnapshot, +} from "./requestLedger.js"; +import { + worldRequestLedgerRecordCodeUnits, + type WorldRequestLedgerSnapshotRecord, +} from "./requestLedgerSnapshot.js"; + +const authority: WorldRequestAuthority = Object.freeze({ principal: "principal-red", run_id: "run-1", world_id: "pitch", world_instance_id: "instance-1" }); +const identity = Object.freeze({ run_id: authority.run_id, world_id: authority.world_id, world_instance_id: authority.world_instance_id, manifest_digest: `sha256:${"a".repeat(64)}`, state_version: 0 }); +const action = (requestId = "request-1", input: unknown = { direction: 1 }) => { + const bytes = encodeWorldActEnvelope({ request_id: requestId, affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/red", input }); + const receipt = Object.freeze({ disposition: "queued" as const, receipt_id: "world-act-1", decision_id: "decision-000000000001", identity, apply_tick: 0 }); + const queued = Object.freeze({ receipt_id: receipt.receipt_id, decision_id: receipt.decision_id, principal: authority.principal, + holder: "world://pitch/entity/red", affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/red", + at_tick: 0, dynamics_sequence: 1, mechanics_action: "kick", mechanics_actor: "object:red", mechanics_target: "object:red", + lowered_input: Object.freeze({ direction: 1 }), identity }); + return { bytes, receipt, queued }; +}; +const claim = (ledger: WorldRequestLedger, bytes: Uint8Array, scope = authority) => ledger.beginClaim({ bytes, authority: scope }); +const commit = (ledger: WorldRequestLedger, requestId = "request-1", input: unknown = { direction: 1 }) => { + const value = action(requestId, input); const result = claim(ledger, value.bytes); + assert.equal(result.kind, "new"); + if (result.kind !== "new") throw new Error("expected reservation"); + result.reservation.prepare({ at_tick: 0, queued_action: value.queued, receipt: value.receipt }); + result.reservation.commit(); + return value; +}; +const emptySnapshot = (ledger: WorldRequestLedger): WorldRequestLedgerSnapshot => ledger.snapshot(); +const prepare = (ledger: WorldRequestLedger, value: ReturnType) => { + const result = claim(ledger, value.bytes); assert.equal(result.kind, "new"); + if (result.kind !== "new") throw new Error("expected reservation"); + result.reservation.prepare({ at_tick: 0, queued_action: value.queued, receipt: value.receipt }); + return result.reservation; +}; + +test("a claim is prepare-before-commit, abortable, and stale handles fail", () => { + const ledger = createWorldRequestLedger(); const value = action(); const result = claim(ledger, value.bytes); + assert.equal(result.kind, "new"); if (result.kind !== "new") throw new Error("expected new"); + assert.equal(result.envelope.request_id, "request-1"); + assert.equal(Object.isFrozen(result.envelope), true); + assert.deepEqual(result.envelope.bytes, Array.from(value.bytes)); + assert.equal(ledger.size, 0); assert.throws(() => ledger.snapshot(), /not quiescent/u); + result.reservation.prepare({ at_tick: 0, queued_action: value.queued, receipt: value.receipt }); + result.reservation.abort(); assert.equal(ledger.size, 0); assert.deepEqual(ledger.snapshot(), emptySnapshot(ledger)); + assert.throws(() => result.reservation.abort(), /stale/u); + assert.throws(() => result.reservation.commit(), /stale/u); + const retry = claim(ledger, value.bytes); assert.equal(retry.kind, "new"); +}); + +test("exact retries replay the first frozen receipt after external clock-like change", () => { + const ledger = createWorldRequestLedger(); const value = commit(ledger); + const first = claim(ledger, value.bytes); assert.equal(first.kind, "replay"); + if (first.kind !== "replay") throw new Error("expected replay"); + assert.equal(first.receipt.apply_tick, 0); + assert.equal(Object.isFrozen(first.receipt), true); assert.equal(Object.isFrozen(first.receipt.identity), true); + const changedClock = 99; assert.equal(changedClock, 99); + const second = claim(ledger, value.bytes); assert.equal(second.kind, "replay"); + if (second.kind === "replay") assert.deepEqual(second.receipt, first.receipt); +}); + +test("closed ledgers replay exact committed claims but reject new and conflicting claims", () => { + const ledger = createWorldRequestLedger(); const value = commit(ledger); ledger.close(); + const replay = claim(ledger, value.bytes); + assert.equal(replay.kind, "replay"); + if (replay.kind === "replay") assert.deepEqual(replay.receipt, value.receipt); + assert.equal(claim(ledger, action("new-after-close").bytes).kind, "conflict"); + assert.equal(claim(ledger, action("request-1", { direction: 2 }).bytes).kind, "conflict"); + assert.equal(ledger.snapshot().record_count, 1); +}); + +test("changed bytes and every authority component conflict under one id", () => { + const ledger = createWorldRequestLedger(); const first = commit(ledger); const changed = action("request-1", { direction: 2 }); + assert.equal(claim(ledger, changed.bytes).kind, "conflict"); + for (const key of ["principal", "run_id", "world_id", "world_instance_id"] as const) { + const scope = { ...authority, [key]: `${authority[key]}-changed` }; + assert.equal(claim(ledger, first.bytes, scope).kind, "conflict"); + } +}); + +test("concurrent reservations conflict and capacity closes safely", () => { + const ledger = createWorldRequestLedger({ max_records: 1 }); const value = action(); + const first = claim(ledger, value.bytes); assert.equal(first.kind, "new"); + assert.equal(claim(ledger, value.bytes).kind, "conflict"); + if (first.kind !== "new") throw new Error("expected new"); + first.reservation.abort(); commit(ledger); + assert.throws(() => claim(ledger, action("request-2").bytes), /capacity/u); + assert.equal(ledger.closed, true); assert.equal(ledger.snapshot().closed, true); + assert.throws(() => first.reservation.commit(), /stale/u); +}); + +test("snapshot restore is exact once, cloned, and continues with a new request", () => { + const source = createWorldRequestLedger(); const first = commit(source); const snapshot = source.snapshot(); + const target = createWorldRequestLedger(); target.restore(snapshot); + assert.deepEqual(target.snapshot(), snapshot); assert.notEqual(target.snapshot().records[0], snapshot.records[0]); + assert.throws(() => target.restore(snapshot), /pristine/u); + const next = action("request-2"); const result = claim(target, next.bytes); assert.equal(result.kind, "new"); + if (result.kind !== "new") throw new Error("expected new"); + result.reservation.prepare({ at_tick: 1, queued_action: Object.freeze({ ...next.queued, receipt_id: "world-act-2", decision_id: "decision-000000000002", dynamics_sequence: 2, at_tick: 1 }), receipt: Object.freeze({ ...next.receipt, receipt_id: "world-act-2", decision_id: "decision-000000000002", apply_tick: 1 }) }); + result.reservation.commit(); assert.equal(target.size, 2); assert.equal(first.receipt.apply_tick, 0); +}); + +test("a full retained ledger restores under the default limits and replays", () => { + const source = createWorldRequestLedger(); + const values = Array.from({ length: 20 }, (_, index) => { + const requestId = `large-${String(index).padStart(2, "0")}`; + const input = { payload: "x".repeat(3_500), index }; + const value = action(requestId, input); + commit(source, requestId, input); + return value; + }); + const snapshot = source.snapshot(); + assert.equal(snapshot.record_count, 20); + assert.equal(snapshot.code_units > 65_536, true); + + const target = createWorldRequestLedger(); + target.restore(snapshot); + assert.deepEqual(target.snapshot(), snapshot); + for (const index of [0, 10, 19]) { + const replay = claim(target, values[index]!.bytes); + assert.equal(replay.kind, "replay"); + if (replay.kind === "replay") assert.deepEqual(replay.receipt, values[index]!.receipt); + } +}); + +test("hostile or inconsistent snapshots leave a pristine target unchanged", () => { + const source = createWorldRequestLedger(); commit(source); const baseline = source.snapshot(); + const malformed: unknown[] = [ + { ...baseline, version: "other.v1" }, + { ...baseline, record_count: 0 }, + { ...baseline, code_units: baseline.code_units + 1 }, + { ...baseline, records: [{ ...baseline.records[0], request_id: "other" }] }, + { ...baseline, records: [{ ...baseline.records[0], request_bytes: [...baseline.records[0]!.request_bytes, 0] }] }, + { ...baseline, records: [{ ...baseline.records[0], queued_action: { ...baseline.records[0]!.queued_action, target: "world://pitch/entity/blue" } }] }, + { ...baseline, records: [baseline.records[0], baseline.records[0]] }, + ]; + const alias = { ...baseline, records: [] as unknown[] }; alias.records.push(baseline.records[0], baseline.records[0]); malformed.push(alias); + for (const input of malformed) { + const target = createWorldRequestLedger(); const before = target.snapshot(); + assert.throws(() => target.restore(input), /invalid|snapshot/u); + assert.deepEqual(target.snapshot(), before); assert.equal(target.size, 0); assert.equal(target.closed, false); + } + const hostile = { ...baseline, records: [{ ...baseline.records[0], authority: { ...baseline.records[0]!.authority, get principal() { throw new Error("accessor"); } } }] }; + const target = createWorldRequestLedger(); assert.throws(() => target.restore(hostile), /invalid|snapshot/u); assert.equal(target.size, 0); +}); + +test("root and records-array hostile shapes reject without invoking traps", () => { + const source = createWorldRequestLedger(); commit(source); const baseline = source.snapshot(); + let traps = 0; + const rootProxy = new Proxy(baseline, { + get() { traps += 1; throw new Error("trap"); }, + ownKeys() { traps += 1; throw new Error("trap"); }, + }); + const accessorRoot = { ...baseline } as Record; + Object.defineProperty(accessorRoot, "records", { enumerable: true, get() { traps += 1; throw new Error("getter"); } }); + const recordProxy = new Proxy(baseline.records, { + get() { traps += 1; throw new Error("trap"); }, + ownKeys() { traps += 1; throw new Error("trap"); }, + }); + const recordSubclass = [...baseline.records]; Object.setPrototypeOf(recordSubclass, { }); + const sparseRecords = [...baseline.records]; delete sparseRecords[0]; + const extraRecords = [...baseline.records] as unknown[] & { extra?: boolean }; extraRecords.extra = true; + const malformed: unknown[] = [ + rootProxy, + accessorRoot, + { ...baseline, records: recordProxy }, + { ...baseline, records: recordSubclass }, + { ...baseline, records: sparseRecords }, + { ...baseline, records: extraRecords }, + { ...baseline, extra: true }, + ]; + for (const input of malformed) { + const target = createWorldRequestLedger(); const before = target.snapshot(); + assert.throws(() => target.restore(input), /invalid|snapshot/u); + assert.deepEqual(target.snapshot(), before); + assert.equal(target.size, 0); assert.equal(target.closed, false); + } + assert.equal(traps, 0); +}); + +test("prepared units are cumulative and failed capacity close leaves no replay", () => { + const first = action("a"); const second = action("b"); + const size = (value: ReturnType): number => { const ledger = createWorldRequestLedger(); commit(ledger, value.bytes ? value === first ? "a" : "b" : "x"); return ledger.snapshot().code_units; }; + const cap = Math.max(size(first), size(second)); const ledger = createWorldRequestLedger({ max_code_units: cap }); + const one = prepare(ledger, first); assert.throws(() => prepare(ledger, second), /capacity/u); + assert.equal(ledger.closed, true); assert.equal(ledger.size, 0); assert.deepEqual(ledger.snapshot().records, []); + assert.throws(() => one.commit(), /stale/u); assert.equal(claim(ledger, first.bytes).kind, "conflict"); +}); + +test("distinct prepared requests commit in either order within the cumulative cap", () => { + for (const reverse of [false, true]) { + const first = action("order-a"); const second = action("order-b"); + const sizing = createWorldRequestLedger(); commit(sizing, "order-a"); const cap = sizing.snapshot().code_units * 2; + const ledger = createWorldRequestLedger({ max_code_units: cap }); const left = prepare(ledger, first); const right = prepare(ledger, second); + (reverse ? right : left).commit(); (reverse ? left : right).commit(); assert.equal(ledger.size, 2); assert.equal(ledger.snapshot().code_units <= cap, true); + } +}); + +test("abort releases prepared units and explicit close makes all handles stale", () => { + const ledger = createWorldRequestLedger(); const first = prepare(ledger, action("abort-a")); first.abort(); + const later = prepare(ledger, action("abort-b")); later.commit(); assert.equal(ledger.size, 1); + const unprepared = claim(ledger, action("close-a").bytes); assert.equal(unprepared.kind, "new"); + if (unprepared.kind !== "new") throw new Error("expected reservation"); + const prepared = prepare(ledger, action("close-b")); ledger.close(); + assert.equal(ledger.snapshot().closed, true); assert.equal(ledger.snapshot().record_count, 1); + assert.throws(() => unprepared.reservation.abort(), /stale/u); assert.throws(() => prepared.commit(), /stale/u); + assert.equal(claim(ledger, action("close-c").bytes).kind, "conflict"); +}); + +test("reservation lifecycle rejects double prepare, premature commit, double commit, and late abort", () => { + const ledger = createWorldRequestLedger(); const value = action("lifecycle"); const result = claim(ledger, value.bytes); + assert.equal(result.kind, "new"); if (result.kind !== "new") throw new Error("expected reservation"); + assert.throws(() => result.reservation.commit(), /invalid/u); + result.reservation.prepare({ at_tick: 0, queued_action: value.queued, receipt: value.receipt }); + assert.throws(() => result.reservation.prepare({ at_tick: 0, queued_action: value.queued, receipt: value.receipt }), /invalid/u); + result.reservation.commit(); assert.throws(() => result.reservation.commit(), /invalid/u); assert.throws(() => result.reservation.abort(), /stale/u); +}); + +test("reordered records reject before restore changes the pristine target", () => { + const source = createWorldRequestLedger(); commit(source, "order-a"); commit(source, "order-b"); + const snapshot = source.snapshot(); const target = createWorldRequestLedger(); + assert.throws(() => target.restore({ ...snapshot, records: [...snapshot.records].reverse() }), /invalid/u); + assert.deepEqual(target.snapshot(), { version: snapshot.version, closed: false, record_count: 0, code_units: 0, records: [] }); +}); + +test("snapshot relationships and claimed bytes are immutable identities", () => { + const ledger = createWorldRequestLedger(); const value = action("identity"); const reservation = prepare(ledger, value); + const retained = [...reservation.request_bytes]; value.bytes[0] ^= 1; reservation.commit(); + assert.equal(claim(ledger, Uint8Array.from(retained)).kind, "replay"); + const snapshot = ledger.snapshot(); const record = snapshot.records[0]!; + const alteredEnvelope = encodeWorldActEnvelope({ request_id: "identity", affordance: "world://pitch/affordance/jump", target: "world://pitch/entity/red", input: { direction: 1 } }); + for (const altered of [ + { ...record, request_bytes: [...alteredEnvelope] }, + { ...record, queued_action: { ...record.queued_action, target: "world://pitch/entity/sun" } }, + { ...record, receipt: { ...record.receipt, identity: { ...record.receipt.identity, state_version: 1 } } }, + { ...record, queued_action: { ...record.queued_action, identity: { ...record.queued_action.identity, state_version: 1 } } }, + ] satisfies WorldRequestLedgerSnapshotRecord[]) { + const target = createWorldRequestLedger(); + assert.throws(() => target.restore({ + ...snapshot, + code_units: worldRequestLedgerRecordCodeUnits(altered), + records: [altered], + }), /invalid/u); + assert.equal(target.size, 0); + } + const changedLowering = { + ...record, + queued_action: { ...record.queued_action, lowered_input: { direction: 22_222 } }, + }; + const target = createWorldRequestLedger(); + assert.throws(() => target.restore({ ...snapshot, records: [changedLowering] }), /invalid/u); + assert.equal(target.size, 0); +}); diff --git a/src/world/requestLedger.ts b/src/world/requestLedger.ts new file mode 100644 index 0000000..18c6b51 --- /dev/null +++ b/src/world/requestLedger.ts @@ -0,0 +1,150 @@ +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import type { QueuedWorldAction, WorldActQueuedReceipt } from "./actTypes.js"; +import { parseWorldActEnvelope, type ParsedWorldActEnvelope } from "./actEnvelope.js"; +import { copySafeUint8Array, readDataObject } from "./decisionRegistrySnapshot.js"; +import { + parseWorldRequestLedgerSnapshot, + sameWorldRequestAuthority, + sameWorldRequestBytes, + worldRequestLedgerRecordCodeUnits, + type WorldRequestAuthority, + type WorldRequestLedgerSnapshot, + type WorldRequestLedgerSnapshotRecord, +} from "./requestLedgerSnapshot.js"; +import { parseQueuedWorldAction, parseWorldActionReceipt } from "./actionJournalSnapshot.js"; + +export type { WorldRequestAuthority, WorldRequestLedgerSnapshot, WorldRequestLedgerSnapshotRecord } from "./requestLedgerSnapshot.js"; + +export interface WorldRequestLedgerClaimInput { readonly bytes: Uint8Array; readonly authority: WorldRequestAuthority; } +export interface WorldRequestReservation { + readonly request_id: string; + readonly request_bytes: readonly number[]; + prepare(input: WorldRequestPreparation): void; + commit(): void; + abort(): void; +} +export interface WorldRequestPreparation { readonly at_tick: number; readonly queued_action: QueuedWorldAction; readonly receipt: WorldActQueuedReceipt; } +export type WorldRequestClaimResult = + | Readonly<{ kind: "new"; envelope: ParsedWorldActEnvelope; reservation: WorldRequestReservation }> + | Readonly<{ kind: "replay"; receipt: WorldActQueuedReceipt }> + | Readonly<{ kind: "malformed" }> + | Readonly<{ kind: "conflict" }>; +export interface WorldRequestLedgerConfig { readonly max_records?: number; readonly max_code_units?: number; } +export interface WorldRequestLedger { + beginClaim(input: unknown): WorldRequestClaimResult; + begin(input: unknown): WorldRequestClaimResult; + snapshot(): WorldRequestLedgerSnapshot; + restore(input: unknown): void; + close(): void; + readonly size: number; + readonly closed: boolean; +} + +type InternalRecord = WorldRequestLedgerSnapshotRecord; +type Slot = { state: "claimed" | "prepared" | "committed"; record?: InternalRecord; units: number; stale: boolean }; +const issuedLedgers = new WeakSet(); +const binding = (value: unknown): value is string => typeof value === "string" && value.length > 0 + && value.length <= DYNAMICS_LIMITS.identifier_code_units && value === value.trim(); +const tick = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const frozen = (value: Value): Value => Object.freeze(value); +const stale = (): never => { throw new Error("stale world request reservation"); }; +const invalid = (): never => { throw new Error("invalid world request reservation"); }; +const closedError = (): Error => new Error("world request ledger closed"); + +const safeAuthority = (value: unknown): WorldRequestAuthority | undefined => { + const source = readDataObject(value, ["principal", "run_id", "world_id", "world_instance_id"]); + if (source === undefined || Reflect.ownKeys(value as object).length !== 4 || !binding(source.principal) + || !binding(source.run_id) || !binding(source.world_id) || !binding(source.world_instance_id)) return undefined; + return frozen({ principal: source.principal, run_id: source.run_id, world_id: source.world_id, world_instance_id: source.world_instance_id }); +}; +const safeClaim = (value: unknown): WorldRequestLedgerClaimInput | undefined => { + const source = readDataObject(value, ["bytes", "authority"]); + if (source === undefined || Reflect.ownKeys(value as object).length !== 2) return undefined; + const bytes = copySafeUint8Array(source.bytes); const authority = safeAuthority(source.authority); + return bytes === undefined || authority === undefined ? undefined : { bytes, authority }; +}; +const copyReceipt = (receipt: WorldActQueuedReceipt): WorldActQueuedReceipt => frozen({ + disposition: "queued", receipt_id: receipt.receipt_id, decision_id: receipt.decision_id, + identity: frozen({ ...receipt.identity }), apply_tick: receipt.apply_tick, +}); +const identityMatches = (authority: WorldRequestAuthority, action: QueuedWorldAction, receipt: WorldActQueuedReceipt): boolean => + action.principal === authority.principal && action.identity.run_id === authority.run_id + && action.identity.world_id === authority.world_id && action.identity.world_instance_id === authority.world_instance_id + && receipt.identity.run_id === authority.run_id && receipt.identity.world_id === authority.world_id + && receipt.identity.world_instance_id === authority.world_instance_id; + +const makeRecord = (envelope: ParsedWorldActEnvelope, authority: WorldRequestAuthority, input: WorldRequestPreparation): InternalRecord | undefined => { + if (!tick(input.at_tick)) return undefined; + const match = typeof input.receipt.receipt_id === "string" ? /^world-act-([1-9][0-9]*)$/u.exec(input.receipt.receipt_id) : null; + const sequence = match === null ? NaN : Number(match[1]); + if (!Number.isSafeInteger(sequence) || sequence < 1) return undefined; + const receipt = parseWorldActionReceipt(input.receipt, sequence); + const action = receipt === undefined ? undefined : parseQueuedWorldAction(input.queued_action, receipt, sequence); + if (receipt === undefined || action === undefined || input.at_tick !== receipt.apply_tick || action.at_tick !== input.at_tick + || action.affordance !== envelope.affordance || action.target !== envelope.target || !identityMatches(authority, action, receipt)) return undefined; + return frozen({ request_id: envelope.request_id, authority: frozen({ ...authority }), request_bytes: frozen(Array.from(envelope.bytes)), + at_tick: input.at_tick, queued_action: action, receipt }); +}; + +export const readWorldRequestLedger = (value: unknown): WorldRequestLedger | undefined => + value !== null && typeof value === "object" && issuedLedgers.has(value) ? value as WorldRequestLedger : undefined; + +export const createWorldRequestLedger = (config: WorldRequestLedgerConfig = {}): WorldRequestLedger => { + const maxRecords = config.max_records ?? DYNAMICS_LIMITS.retained_action_records; + const maxCodeUnits = config.max_code_units ?? DYNAMICS_LIMITS.retained_action_code_units; + if (!Number.isSafeInteger(maxRecords) || maxRecords < 1 || maxRecords > DYNAMICS_LIMITS.retained_action_records + || !Number.isSafeInteger(maxCodeUnits) || maxCodeUnits < 1 || maxCodeUnits > DYNAMICS_LIMITS.retained_action_code_units) throw new TypeError("invalid world request ledger limits"); + const slots = new Map(); + let committedUnits = 0; let reservedUnits = 0; let committedSize = 0; let closed = false; let restoreAllowed = true; + const closeLedger = (): void => { + closed = true; + for (const [id, slot] of slots) if (slot.state !== "committed") { slot.stale = true; slots.delete(id); reservedUnits -= slot.units; } + }; + const closeAndThrow = (message: string): never => { closeLedger(); throw new Error(message); }; + const beginClaim = (input: unknown): WorldRequestClaimResult => { + const claim = safeClaim(input); if (claim === undefined) return { kind: "malformed" }; + let envelope: ParsedWorldActEnvelope; try { envelope = parseWorldActEnvelope(claim.bytes); } catch { return { kind: "malformed" }; } + const existing = slots.get(envelope.request_id); + if (existing?.state === "committed" && existing.record !== undefined) { + return sameWorldRequestBytes(existing.record.request_bytes, envelope.bytes) && sameWorldRequestAuthority(existing.record.authority, claim.authority) + ? frozen({ kind: "replay", receipt: copyReceipt(existing.record.receipt) }) : frozen({ kind: "conflict" }); + } + if (closed) return frozen({ kind: "conflict" }); + if (existing !== undefined) return frozen({ kind: "conflict" }); + if (slots.size >= maxRecords || envelope.bytes.length > maxCodeUnits) return closeAndThrow("world request ledger capacity exhausted"); + const slot: Slot = { state: "claimed", units: 0, stale: false }; slots.set(envelope.request_id, slot); + const requireLive = (): void => { if (closed || slot.stale || slots.get(envelope.request_id) !== slot) stale(); }; + const prepare = (preparation: WorldRequestPreparation): void => { + requireLive(); if (slot.state !== "claimed" || preparation === null || typeof preparation !== "object") invalid(); + const source = readDataObject(preparation, ["at_tick", "queued_action", "receipt"]); + if (source === undefined || Reflect.ownKeys(preparation).length !== 3) invalid(); + const record = makeRecord(envelope, claim.authority, source as unknown as WorldRequestPreparation); if (record === undefined) return invalid(); + const units = worldRequestLedgerRecordCodeUnits(record); + if (units > maxCodeUnits || committedUnits + reservedUnits + units > maxCodeUnits) closeAndThrow("world request ledger capacity exhausted"); + slot.record = record; slot.units = units; slot.state = "prepared"; reservedUnits += units; + }; + const commit = (): void => { requireLive(); if (slot.state !== "prepared" || slot.record === undefined) invalid(); + slot.state = "committed"; reservedUnits -= slot.units; committedUnits += slot.units; committedSize += 1; }; + const abort = (): void => { requireLive(); if (slot.state !== "claimed" && slot.state !== "prepared") stale(); + if (slot.state === "prepared") reservedUnits -= slot.units; slot.stale = true; slots.delete(envelope.request_id); }; + const reservation = frozen({ request_id: envelope.request_id, request_bytes: frozen(Array.from(envelope.bytes)), prepare, commit, abort }); + return frozen({ kind: "new", envelope, reservation }); + }; + const snapshot = (): WorldRequestLedgerSnapshot => { + if ([...slots.values()].some((slot) => slot.state !== "committed")) throw new Error("world request ledger is not quiescent"); + const records = [...slots.values()].filter((slot): slot is Slot & { record: InternalRecord } => slot.state === "committed" && slot.record !== undefined) + .map((slot) => frozen({ ...slot.record, authority: frozen({ ...slot.record.authority }), request_bytes: frozen(Array.from(slot.record.request_bytes)), receipt: copyReceipt(slot.record.receipt) })) + .sort((left, right) => left.request_id === right.request_id ? 0 : left.request_id < right.request_id ? -1 : 1); + return frozen({ version: "simfile.world-request-ledger.v1", closed, record_count: records.length, code_units: committedUnits, records: frozen(records) }); + }; + const restore = (input: unknown): void => { + if (!restoreAllowed || closed || slots.size !== 0 || committedUnits !== 0 || reservedUnits !== 0) throw new Error("world request ledger is not pristine"); + const parsed = parseWorldRequestLedgerSnapshot(input); + if (parsed === undefined || parsed.records.length > maxRecords || parsed.code_units > maxCodeUnits) throw new TypeError("invalid world request ledger snapshot"); + for (const record of parsed.records) slots.set(record.request_id, { state: "committed", record, units: worldRequestLedgerRecordCodeUnits(record), stale: false }); + committedUnits = parsed.code_units; committedSize = parsed.records.length; closed = parsed.closed; restoreAllowed = false; + }; + const ledger: WorldRequestLedger = Object.freeze({ beginClaim, begin: beginClaim, snapshot, restore, close: closeLedger, + get size(): number { return committedSize; }, get closed(): boolean { return closed; } }); + issuedLedgers.add(ledger as object); return ledger; +}; diff --git a/src/world/requestLedgerInspection.ts b/src/world/requestLedgerInspection.ts new file mode 100644 index 0000000..3d8967b --- /dev/null +++ b/src/world/requestLedgerInspection.ts @@ -0,0 +1,16 @@ +import type { WorldRequestLedger } from "./requestLedger.js"; +import type { WorldRequestLedgerSnapshot } from "./requestLedgerSnapshot.js"; + +export interface WorldRequestLedgerInspection { + snapshot(): WorldRequestLedgerSnapshot; +} + +const inspections = new WeakMap(); + +export const registerWorldRuntimeRequestLedgerInspection = (runtime: object, ledger: WorldRequestLedger): void => { + if (inspections.has(runtime)) throw new Error("world request ledger inspection already issued"); + inspections.set(runtime, Object.freeze({ snapshot: () => ledger.snapshot() })); +}; + +export const readWorldRuntimeRequestLedgerInspection = (runtime: unknown): WorldRequestLedgerInspection | undefined => + runtime !== null && typeof runtime === "object" ? inspections.get(runtime) : undefined; diff --git a/src/world/requestLedgerSnapshot.ts b/src/world/requestLedgerSnapshot.ts new file mode 100644 index 0000000..0249678 --- /dev/null +++ b/src/world/requestLedgerSnapshot.ts @@ -0,0 +1,185 @@ +import { types } from "node:util"; + +import { canonicalDynamicsJson } from "../dynamics/canonicalJson.js"; +import { DYNAMICS_LIMITS } from "../dynamics/limits.js"; +import type { QueuedWorldAction, WorldActQueuedReceipt } from "./actTypes.js"; +import { parseWorldActEnvelope } from "./actEnvelope.js"; +import { copyHostileJson, type HostileJson } from "./hostileJson.js"; +import { parseQueuedWorldAction, parseWorldActionReceipt } from "./actionJournalSnapshot.js"; + +export const WORLD_REQUEST_LEDGER_SNAPSHOT_VERSION = "simfile.world-request-ledger.v1" as const; + +export interface WorldRequestAuthority { + readonly principal: string; + readonly run_id: string; + readonly world_id: string; + readonly world_instance_id: string; +} + +export interface WorldRequestLedgerSnapshotRecord { + readonly request_id: string; + readonly authority: WorldRequestAuthority; + readonly request_bytes: readonly number[]; + readonly at_tick: number; + readonly queued_action: QueuedWorldAction; + readonly receipt: WorldActQueuedReceipt; +} + +export interface WorldRequestLedgerSnapshot { + readonly version: typeof WORLD_REQUEST_LEDGER_SNAPSHOT_VERSION; + readonly closed: boolean; + readonly record_count: number; + readonly code_units: number; + readonly records: readonly WorldRequestLedgerSnapshotRecord[]; +} + +type JsonRecord = Readonly>; +const binding = (value: unknown): value is string => typeof value === "string" && value.length > 0 + && value.length <= DYNAMICS_LIMITS.identifier_code_units && value === value.trim(); +const tick = (value: unknown): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= 0; +const fail = (): undefined => undefined; +const frozen = (value: Value): Value => Object.freeze(value); +const compareUtf16 = (left: string, right: string): number => left === right ? 0 : left < right ? -1 : 1; +const readFields = (value: unknown, expected: readonly string[], allowNullPrototype = false): JsonRecord | undefined => { + if (types.isProxy(value) || value === null || typeof value !== "object" || + Array.isArray(value) || (Object.getPrototypeOf(value) !== Object.prototype && + (!allowNullPrototype || Object.getPrototypeOf(value) !== null))) return fail(); + const keys = Reflect.ownKeys(value); + if (keys.length !== expected.length || keys.some((key) => typeof key !== "string" || !expected.includes(key))) return fail(); + const result: Record = {}; + for (const key of expected) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) return fail(); + result[key] = descriptor.value as HostileJson; + } + return result; +}; +const readArray = (value: unknown): readonly unknown[] | undefined => { + if (types.isProxy(value) || !Array.isArray(value) || Object.getPrototypeOf(value) !== Array.prototype) return fail(); + const lengthDescriptor = Object.getOwnPropertyDescriptor(value, "length"); + if (lengthDescriptor === undefined || !("value" in lengthDescriptor) || + !Number.isSafeInteger(lengthDescriptor.value) || lengthDescriptor.value < 0 || + lengthDescriptor.value > DYNAMICS_LIMITS.retained_action_records) return fail(); + const length = lengthDescriptor.value; + const keys = Reflect.ownKeys(value); + if (keys.length !== length + 1 || !keys.includes("length") || keys.some((key) => + typeof key !== "string" || (key !== "length" && (!/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= length)))) return fail(); + const result: unknown[] = []; + for (let index = 0; index < length; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) return fail(); + result.push(descriptor.value); + } + return result; +}; +const array = (value: HostileJson): readonly HostileJson[] | undefined => Array.isArray(value) ? value : fail(); +const equalBytes = (left: readonly number[], right: readonly number[]): boolean => + left.length === right.length && left.every((byte, index) => byte === right[index]); +const equalAuthority = (left: WorldRequestAuthority, right: WorldRequestAuthority): boolean => + left.principal === right.principal && left.run_id === right.run_id && left.world_id === right.world_id + && left.world_instance_id === right.world_instance_id; + +const parseAuthority = (value: HostileJson): WorldRequestAuthority | undefined => { + const source = readFields(value, ["principal", "run_id", "world_id", "world_instance_id"], true); + if (source === undefined || !binding(source.principal) || !binding(source.run_id) + || !binding(source.world_id) || !binding(source.world_instance_id)) return fail(); + return frozen({ principal: source.principal, run_id: source.run_id, world_id: source.world_id, world_instance_id: source.world_instance_id }); +}; + +const parseBytes = (value: HostileJson): readonly number[] | undefined => { + const values = array(value); + if (values === undefined || values.length === 0 || values.length > DYNAMICS_LIMITS.retained_action_code_units) return fail(); + const bytes: number[] = []; + for (const byte of values) { + if (typeof byte !== "number" || !Number.isInteger(byte) || byte < 0 || byte > 255) return fail(); + bytes.push(byte); + } + return frozen(bytes); +}; + +const sequenceOf = (receiptId: unknown): number | undefined => { + if (typeof receiptId !== "string") return undefined; + const match = /^world-act-([1-9][0-9]*)$/u.exec(receiptId); + if (match === null) return undefined; + const sequence = Number(match[1]); + return Number.isSafeInteger(sequence) && sequence >= 1 ? sequence : undefined; +}; + +const recordCodeUnits = (record: WorldRequestLedgerSnapshotRecord): number => canonicalDynamicsJson({ + request_id: record.request_id, + authority: record.authority, + request_bytes: record.request_bytes, + at_tick: record.at_tick, + queued_action: record.queued_action, + receipt: record.receipt, +}).length; + +const parseRecord = (value: HostileJson): WorldRequestLedgerSnapshotRecord | undefined => { + const source = readFields(value, ["request_id", "authority", "request_bytes", "at_tick", "queued_action", "receipt"], true); + if (source === undefined || !binding(source.request_id) || !tick(source.at_tick)) return fail(); + const authority = parseAuthority(source.authority); + const requestBytes = parseBytes(source.request_bytes); + if (authority === undefined || requestBytes === undefined) return fail(); + let envelope: ReturnType; + try { envelope = parseWorldActEnvelope(Uint8Array.from(requestBytes)); } catch { return fail(); } + if (envelope.request_id !== source.request_id) return fail(); + const sequence = sequenceOf((source.receipt as JsonRecord | undefined)?.receipt_id); + if (sequence === undefined) return fail(); + const receipt = parseWorldActionReceipt(source.receipt, sequence); + const queued = receipt === undefined ? undefined : parseQueuedWorldAction(source.queued_action, receipt, sequence); + if (receipt === undefined || queued === undefined || queued.principal !== authority.principal + || queued.affordance !== envelope.affordance || queued.target !== envelope.target + || queued.at_tick !== source.at_tick || receipt.apply_tick !== source.at_tick + || receipt.identity.run_id !== authority.run_id || receipt.identity.world_id !== authority.world_id + || receipt.identity.world_instance_id !== authority.world_instance_id) return fail(); + const record: WorldRequestLedgerSnapshotRecord = frozen({ + request_id: source.request_id, + authority, + request_bytes: requestBytes, + at_tick: source.at_tick, + queued_action: queued, + receipt, + }); + return recordCodeUnits(record) <= DYNAMICS_LIMITS.retained_action_code_units ? record : fail(); +}; + +const parse = (input: unknown): WorldRequestLedgerSnapshot | undefined => { + const source = readFields(input, ["version", "closed", "record_count", "code_units", "records"]); + const values = source === undefined ? undefined : readArray(source.records); + if (source === undefined || source.version !== WORLD_REQUEST_LEDGER_SNAPSHOT_VERSION || typeof source.closed !== "boolean" + || values === undefined || values.length > DYNAMICS_LIMITS.retained_action_records + || typeof source.record_count !== "number" || !Number.isSafeInteger(source.record_count) + || source.record_count < 0 || source.record_count > DYNAMICS_LIMITS.retained_action_records + || source.record_count !== values.length || typeof source.code_units !== "number" + || !Number.isSafeInteger(source.code_units) || source.code_units < 0 + || source.code_units > DYNAMICS_LIMITS.retained_action_code_units) return fail(); + const records: WorldRequestLedgerSnapshotRecord[] = []; + const ids = new Set(); + for (const value of values) { + let record: WorldRequestLedgerSnapshotRecord | undefined; + try { record = parseRecord(copyHostileJson(value)); } catch { return fail(); } + if (record === undefined || ids.has(record.request_id)) return fail(); + ids.add(record.request_id); records.push(record); + } + for (let index = 1; index < records.length; index += 1) { + if (compareUtf16(records[index - 1]!.request_id, records[index]!.request_id) >= 0) return fail(); + } + const codeUnits = records.reduce((total, record) => total + recordCodeUnits(record), 0); + if (codeUnits !== source.code_units) return fail(); + return frozen({ version: WORLD_REQUEST_LEDGER_SNAPSHOT_VERSION, closed: source.closed, + record_count: records.length, code_units: codeUnits, records: frozen(records) }); +}; + +export const parseWorldRequestLedgerSnapshot = (input: unknown): WorldRequestLedgerSnapshot | undefined => { + try { return parse(input); } catch { return undefined; } +}; + +export const cloneWorldRequestLedgerSnapshot = (input: WorldRequestLedgerSnapshot): WorldRequestLedgerSnapshot => { + const parsed = parseWorldRequestLedgerSnapshot(input); + if (parsed === undefined) throw new TypeError("invalid world request ledger snapshot"); + return parsed; +}; + +export const sameWorldRequestAuthority = equalAuthority; +export const worldRequestLedgerRecordCodeUnits = recordCodeUnits; +export const sameWorldRequestBytes = equalBytes; diff --git a/src/world/runtime.test-helper.ts b/src/world/runtime.test-helper.ts new file mode 100644 index 0000000..f19aeed --- /dev/null +++ b/src/world/runtime.test-helper.ts @@ -0,0 +1,142 @@ +import { parseWorldSurfaceDefinition } from "../world-surface/index.js"; +import { createDynamicsSession } from "../dynamics/session.js"; +import type { DynamicsSession } from "../dynamics/session.js"; +import type { WorldAffordanceContext, WorldSenseProjectionInput } from "../world-surface/types.js"; +import { validWorldSurface } from "../world-surface/definition.test-helper.js"; +import { compileCapabilityManifests } from "./capabilityManifest.js"; +import { encodeWorldActEnvelope } from "./actEnvelope.js"; +import { createDecisionRegistryForTesting } from "./decisionRegistry.js"; +import { issueBoundWorldGrants } from "./grantAttestation.js"; +import { createWorldReadLedger } from "./ledger.js"; +import { createWorldRuntime } from "./runtime.js"; +import { readWorldRuntimeActionJournalInspection } from "./actionJournalInspection.js"; +import type { WorldActionJournalSnapshot } from "./actionJournalSnapshot.js"; +import type { WorldActionJournalStatus } from "./actionJournal.js"; +import { readWorldRuntimeRequestLedgerInspection } from "./requestLedgerInspection.js"; +import type { WorldRequestLedgerSnapshot } from "./requestLedgerSnapshot.js"; +import { readWorldRuntimeActionResultLedgerInspection } from "./actionResultLedgerInspection.js"; +import type { WorldActionResultLedger } from "./actionResultLedger.js"; + +type RuntimeFixture = Omit, "runtime"> & { readonly runtime: NonNullable["runtime"]> }; +type RuntimeIdentity = { + readonly runId: string; + readonly worldInstanceId: string; + readonly buildReceiptSha256?: string; + readonly moduleSha256?: string; + readonly redPrincipal?: string; + readonly redAffordances?: readonly ("world://pitch/affordance/kick" | "world://pitch/affordance/wait")[]; + readonly redSenses?: readonly ("world://pitch/sense/vision" | "world://pitch/sense/red-detail")[]; + readonly maxLedgerPrincipals?: number; + readonly fixedTargets?: readonly ("entity:ball" | "entity:blue")[]; + /** Keeps lifecycle fixtures on a deliberately short, issued decision window. */ + readonly decisionValidThroughTick?: number; +}; + +export type RuntimeFixtureHooks = { + readonly observe?: (input: unknown, state: Record, dynamics: DynamicsSession) => unknown; + readonly project?: (input: WorldSenseProjectionInput, dynamics: DynamicsSession) => unknown; + readonly projectDetail?: (input: WorldSenseProjectionInput, dynamics: DynamicsSession) => unknown; + readonly available?: (input: WorldAffordanceContext, dynamics: DynamicsSession) => unknown; + readonly lower?: (input: unknown, dynamics: DynamicsSession) => unknown; + /** null deliberately omits the optional checked surface callback. */ + readonly projectResult?: ((input: unknown, dynamics: DynamicsSession) => unknown) | null; + readonly randomBytes?: () => Uint8Array; + /** Makes the real provider's next restore fail; the checked session still owns rollback. */ + readonly failRestore?: () => boolean; + /** Runs inside the real provider's snapshot callback. */ + readonly snapshot?: () => void; + readonly step?: (input: unknown, state: Record, dynamics: DynamicsSession) => unknown; +}; + +export function runtimeFixture(): RuntimeFixture; +export function runtimeFixture(createRuntime: false, identity?: RuntimeIdentity): Omit & { readonly runtime: undefined }; +export function runtimeFixture(createRuntime = true, identity: RuntimeIdentity = { runId: "run-1", worldInstanceId: "instance-1" }) { return buildRuntimeFixture(createRuntime, identity); } +export const runtimeFixtureWithHooks = (hooks: RuntimeFixtureHooks, createRuntime = true, identity: RuntimeIdentity = { runId: "run-1", worldInstanceId: "instance-1" }) => + buildRuntimeFixture(createRuntime, identity, hooks); + +export const runtimeActionJournalSnapshot = (runtime: unknown): WorldActionJournalSnapshot | undefined => + readWorldRuntimeActionJournalInspection(runtime)?.snapshot(); + +export const runtimeActionJournalStatus = (runtime: unknown): WorldActionJournalStatus | undefined => + readWorldRuntimeActionJournalInspection(runtime)?.status(); + +export const runtimeRequestLedgerSnapshot = (runtime: unknown): WorldRequestLedgerSnapshot | undefined => + readWorldRuntimeRequestLedgerInspection(runtime)?.snapshot(); + +export const runtimeActionResultLedger = (runtime: unknown): WorldActionResultLedger | undefined => + readWorldRuntimeActionResultLedgerInspection(runtime); + +export const runtimeActEnvelope = (requestId: string, request: Readonly<{ affordance: string; target: string; input: unknown }>): Uint8Array => + encodeWorldActEnvelope({ request_id: requestId, ...request }); + +const buildRuntimeFixture = (createRuntime: boolean, identity: RuntimeIdentity = { runId: "run-1", worldInstanceId: "instance-1" }, hooks: RuntimeFixtureHooks = {}) => { + const surface = validWorldSurface() as { + entities: Record; senses: Record; affordances: Record; + }; + surface.entities.blue = { address: "entity:blue", dynamics_address: "object:blue" }; + let dynamics: DynamicsSession; + const projectedChannels = (input: WorldSenseProjectionInput) => input.observation.channels.map((channel) => ({ + components: channel.components, + ...(channel.frame === undefined ? {} : { frame: channel.frame }), + sense_address: "sense:vision", + subject_address: "entity:red", + ...(channel.unit === undefined ? {} : { unit: channel.unit }), + })); + surface.senses["sense:vision"] = { + dynamics_senses: ["sense:state"], output: "simfile.numeric-observation.v1", + project: (input: WorldSenseProjectionInput) => hooks.project?.(input, dynamics) ?? { channels: projectedChannels(input) }, + }; + surface.senses["sense:red-detail"] = { dynamics_senses: ["sense:detail"], output: "simfile.numeric-observation.v1", project: (input: WorldSenseProjectionInput) => hooks.projectDetail?.(input, dynamics) ?? ({ channels: projectedChannels(input).map((channel) => ({ ...channel, sense_address: "sense:red-detail" })) }) }; + surface.senses["sense:blue-view"] = { dynamics_senses: ["sense:blue-state"], output: "simfile.numeric-observation.v1", project: () => ({ channels: [] }) }; + if (identity.fixedTargets !== undefined) surface.affordances["affordance:kick"] = { ...surface.affordances["affordance:kick"] as object, target_selector: { kind: "fixed", targets: [...identity.fixedTargets] } }; + if (hooks.available !== undefined) surface.affordances["affordance:kick"] = { ...surface.affordances["affordance:kick"] as object, available: (input: WorldAffordanceContext) => hooks.available!(input, dynamics) }; + if (hooks.lower !== undefined) surface.affordances["affordance:kick"] = { ...surface.affordances["affordance:kick"] as object, lower: (input: unknown) => hooks.lower!(input, dynamics) }; + if (hooks.projectResult === null) { + delete (surface.affordances["affordance:kick"] as Record).project_result; + } else if (hooks.projectResult !== undefined) { + surface.affordances["affordance:kick"] = { ...surface.affordances["affordance:kick"] as object, project_result: (input: unknown) => hooks.projectResult!(input, dynamics) }; + } + surface.affordances["affordance:wait"] = { ...surface.affordances["affordance:kick"] as object, dynamics_action: "wait", target_selector: { kind: "holder" } }; + const surfaceRegistry = parseWorldSurfaceDefinition(surface); + const redPrincipal = identity.redPrincipal ?? "principal-red"; + const boundGrants = issueBoundWorldGrants([ + { participant: "blue", principal: "principal-blue", entity: "world://pitch/entity/blue", senses: ["world://pitch/sense/blue-view"], affordances: ["world://pitch/affordance/wait"] }, + { participant: "red", principal: redPrincipal, entity: "world://pitch/entity/red", senses: identity.redSenses ?? ["world://pitch/sense/vision", "world://pitch/sense/red-detail"], affordances: identity.redAffordances ?? ["world://pitch/affordance/kick", "world://pitch/affordance/wait"] }, + ] as const as never); + const capabilityManifests = compileCapabilityManifests({ runId: identity.runId, worldInstanceId: identity.worldInstanceId, world: { id: "pitch" as never }, surfaceRegistry, grants: boundGrants as never }); + let entropy = 9; + const decisionRegistry = createDecisionRegistryForTesting({ runId: identity.runId, worldInstanceId: identity.worldInstanceId, tokenDigestKey: new Uint8Array(32).fill(7) }, { randomBytes: hooks.randomBytes ?? (() => new Uint8Array(32).fill(entropy++)) }); + const validThroughTick = identity.decisionValidThroughTick ?? 2; + const red = decisionRegistry.mint({ principal: redPrincipal, issuedTick: 0, validThroughTick }); + const blue = decisionRegistry.mint({ principal: "principal-blue", issuedTick: 0, validThroughTick }); + let calls = 0; + let restores = 0; + const providerState: Record = { value: 0 }; + dynamics = createDynamicsSession({ + api_version: "simfile.dynamics-provider.v1", id: "runtime-test", version: "1", state_schema_version: "v1", integration: {}, + initialize: () => {}, + observe: (input) => { + calls += 1; + return (hooks.observe?.(input, providerState, dynamics) ?? { + channels: input.sense_addresses.map((sense_address) => ({ components: { x: 1 }, sense_address, subject_address: "object:red", unit: "meters" })), + }) as never; + }, + restore: (value) => { + restores += 1; + if (hooks.failRestore?.()) throw new Error("configured real provider restore failure"); + Object.assign(providerState, value); + for (const key of Object.keys(providerState)) if (!Object.hasOwn(value as object, key)) delete providerState[key]; + }, + snapshot: () => { hooks.snapshot?.(); return structuredClone(providerState) as never; }, + step: (input) => { calls += 1; return (hooks.step?.(input, providerState, dynamics) ?? { action_results: [], events: [], tick: input.tick }) as never; }, + }, { + buildReceipt: identity.buildReceiptSha256 === undefined ? {} : { receiptSha256: identity.buildReceiptSha256 }, config: {}, seed: "seed", simSecondsPerTick: 1, + provenance: { api_version: "simfile.dynamics-provider.v1", config_sha256: "0".repeat(64), module: "test", module_sha256: identity.moduleSha256 ?? "1".repeat(64), node_version: "test", numeric_model: "ieee754-binary64", provider_dependencies: {}, provider_id: "runtime-test", provider_version: "1", state_schema_version: "v1" }, + } as never); + const readLedger = createWorldReadLedger({ + maxEntriesPerPrincipal: 20, + ...(identity.maxLedgerPrincipals === undefined ? {} : { maxPrincipals: identity.maxLedgerPrincipals }), + }); + const runtime = createRuntime ? createWorldRuntime({ dynamics, surfaceRegistry, capabilityManifests, boundGrants, decisionRegistry, readLedger }) : undefined; + return { runtime, dynamics, dynamicsCalls: () => calls, restoreCalls: () => restores, surfaceRegistry, capabilityManifests, boundGrants, decisionRegistry, readLedger, red, blue }; +}; diff --git a/src/world/runtime.test.ts b/src/world/runtime.test.ts new file mode 100644 index 0000000..c2da21e --- /dev/null +++ b/src/world/runtime.test.ts @@ -0,0 +1,186 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createWorldReadLedger, readWorldReadLedger, WorldRuntimeError } from "./ledger.js"; +import { createWorldRuntime } from "./runtime.js"; +import { runtimeFixture } from "./runtime.test-helper.js"; +import * as world from "./index.js"; + +test("scopes red and blue status and capability documents without mechanics reads", () => { + const fixture = runtimeFixture(); + const beforeDecisions = fixture.decisionRegistry.snapshot(); + const red = fixture.runtime.status({ principal: "principal-red", decisionToken: fixture.red.token }); + const blue = fixture.runtime.capabilities({ principal: "principal-blue", decisionToken: fixture.blue.token }); + assert.deepEqual(red.identity, { run_id: "run-1", world_id: "pitch", world_instance_id: "instance-1", manifest_digest: red.identity.manifest_digest, state_version: 0 }); + assert.equal(red.orientation.holder_entity, "world://pitch/entity/red"); + assert.equal(blue.manifest.holder.entity, "world://pitch/entity/blue"); + assert.notEqual(red.identity.manifest_digest, blue.identity.manifest_digest); + assert.equal(fixture.dynamicsCalls(), 0); + assert.deepEqual(fixture.decisionRegistry.snapshot(), beforeDecisions); + assert.throws(() => (blue.manifest.senses as unknown as unknown[]).push({}), TypeError); +}); + +test("fails closed for wrong and expired admissions and audits a trusted principal", () => { + const fixture = runtimeFixture(); + assert.throws(() => fixture.runtime.status({ principal: "principal-red", decisionToken: fixture.blue.token }), + (error: unknown) => error instanceof WorldRuntimeError && error.code === "world_runtime_denied" && !String(error).includes(fixture.blue.token)); + const page = fixture.runtime.ledger({ principal: "principal-red", decisionToken: fixture.red.token }, {}); + assert.equal(page.records.length, 1); + assert.deepEqual(page.records[0], { sequence: 1, operation: "status", principal: "principal-red", result: "denied" }); + assert.equal(page.next_after, 1); +}); + +test("rejects reordered, duplicate, and copied composition before exposing a runtime", () => { + const fixture = runtimeFixture(); + const source = { dynamics: {}, surfaceRegistry: fixture.surfaceRegistry, capabilityManifests: fixture.capabilityManifests, boundGrants: fixture.boundGrants, decisionRegistry: fixture.decisionRegistry, readLedger: fixture.readLedger }; + for (const input of [ + { ...source, capabilityManifests: [...fixture.capabilityManifests].reverse() }, + { ...source, capabilityManifests: [...fixture.capabilityManifests, fixture.capabilityManifests[0]!] }, + { ...source, surfaceRegistry: { ...fixture.surfaceRegistry } }, + ]) assert.throws(() => createWorldRuntime(input), (error: unknown) => error instanceof WorldRuntimeError && error.code === "world_runtime_invalid_composition"); +}); + +test("a ledger read snapshots before appending itself", () => { + const fixture = runtimeFixture(); + fixture.runtime.status({ principal: "principal-red", decisionToken: fixture.red.token }); + const first = fixture.runtime.ledger({ principal: "principal-red", decisionToken: fixture.red.token }, {}); + assert.deepEqual(first.records.map((record) => record.operation), ["status"]); + const second = fixture.runtime.ledger({ principal: "principal-red", decisionToken: fixture.red.token }, { after: first.next_after }); + assert.deepEqual(second.records.map((record) => record.operation), ["ledger"]); +}); + +test("accepts only issued frozen authorities and exact B18 grant order", () => { + const fixture = runtimeFixture(false); + const base = { dynamics: fixture.dynamics, surfaceRegistry: fixture.surfaceRegistry, capabilityManifests: fixture.capabilityManifests, boundGrants: fixture.boundGrants, decisionRegistry: fixture.decisionRegistry, readLedger: fixture.readLedger }; + for (const input of [ + { ...base, dynamics: {} }, { ...base, decisionRegistry: {} }, { ...base, readLedger: {} }, + { ...base, dynamics: { ...fixture.dynamics } }, { ...base, decisionRegistry: new Proxy(fixture.decisionRegistry, {}) }, { ...base, readLedger: new Proxy(fixture.readLedger, {}) }, + ]) assert.throws(() => createWorldRuntime(input as never), (error: unknown) => error instanceof WorldRuntimeError && error.code === "world_runtime_invalid_composition"); + assert.throws(() => { (fixture.dynamics as unknown as { nextTick: number }).nextTick = 9; }, TypeError); + assert.throws(() => { (fixture.decisionRegistry as unknown as { mint: unknown }).mint = undefined; }, TypeError); + assert.equal("append" in fixture.readLedger, false); + const runtime = createWorldRuntime(base); + assert.equal(runtime.status({ principal: "principal-red", decisionToken: fixture.red.token }).orientation.holder_entity, "world://pitch/entity/red"); + assert.equal((world as Record).createWorldReadLedgerForTesting, undefined); + assert.equal((world as Record).readDecisionRegistry, undefined); + assert.equal((world as Record).readWorldReadLedger, undefined); + assert.equal((world as Record).readBoundWorldGrants, undefined); + assert.equal((world as Record).readParsedWorldSurfaceRegistry, undefined); + assert.equal((world as Record).affordancesWorldRuntime, undefined); + assert.equal((world as Record).observeScopedWorldRuntime, undefined); +}); + +test("rejects every copied or reordered B18 grant set before recompiling B19 manifests", () => { + const fixture = runtimeFixture(false); + const base = { dynamics: fixture.dynamics, surfaceRegistry: fixture.surfaceRegistry, capabilityManifests: fixture.capabilityManifests, decisionRegistry: fixture.decisionRegistry, readLedger: fixture.readLedger }; + const copied = fixture.boundGrants.map((grant) => ({ ...grant, senses: [...grant.senses], affordances: [...grant.affordances] })); + const duplicate = [...fixture.boundGrants, fixture.boundGrants[1]!]; + const omission = fixture.boundGrants.slice(1); + const addition = [...fixture.boundGrants, fixture.boundGrants[0]!]; + const sensesReversed = copied.map((grant) => grant.participant === "red" ? { ...grant, senses: [...grant.senses].reverse() } : grant); + const affordancesReversed = copied.map((grant) => grant.participant === "red" ? { ...grant, affordances: [...grant.affordances].reverse() } : grant); + assert.equal(copied.find((grant) => grant.participant === "red")!.senses.length, 2); + assert.equal(copied.find((grant) => grant.participant === "red")!.affordances.length, 2); + assert.notDeepEqual(sensesReversed, copied); + assert.notDeepEqual(affordancesReversed, copied); + for (const grants of [copied, [...fixture.boundGrants].reverse(), sensesReversed, affordancesReversed, duplicate, omission, addition, new Proxy(fixture.boundGrants, {})]) { + assert.throws(() => createWorldRuntime({ ...base, boundGrants: grants } as never), (error: unknown) => error instanceof WorldRuntimeError && error.code === "world_runtime_invalid_composition"); + } +}); + +test("rejects current bindings that cannot be retained in a ledger record", () => { + for (const identity of [ + { runId: "r".repeat(257), worldInstanceId: "instance-1" }, + { runId: "run-1", worldInstanceId: "i".repeat(257) }, + { runId: "run-1", worldInstanceId: "instance-1", redPrincipal: "p".repeat(257) }, + ]) { + const fixture = runtimeFixture(false, identity); + assert.throws(() => createWorldRuntime({ dynamics: fixture.dynamics, surfaceRegistry: fixture.surfaceRegistry, capabilityManifests: fixture.capabilityManifests, boundGrants: fixture.boundGrants, decisionRegistry: fixture.decisionRegistry, readLedger: fixture.readLedger }), WorldRuntimeError); + } +}); + +test("audits exactly once per attributed result without touching mechanics state", () => { + const fixture = runtimeFixture(); + fixture.runtime.status({ principal: "principal-red", decisionToken: fixture.red.token }); + assert.throws(() => fixture.runtime.capabilities({ principal: "principal-red", decisionToken: fixture.blue.token }), WorldRuntimeError); + const records = fixture.readLedger.read("principal-red", {}).records; + assert.deepEqual(records.map((record) => [record.operation, record.result]), [["status", "allowed"], ["capabilities", "denied"]]); + assert.equal(fixture.dynamicsCalls(), 0); +}); + +test("claims each live authority once and rejects cross-runtime reuse before calls", () => { + const first = runtimeFixture(); + for (const field of ["dynamics", "decisionRegistry", "readLedger"] as const) { + const second = runtimeFixture(false); + assert.throws(() => createWorldRuntime({ + dynamics: field === "dynamics" ? first.dynamics : second.dynamics, + surfaceRegistry: second.surfaceRegistry, capabilityManifests: second.capabilityManifests, boundGrants: second.boundGrants, + decisionRegistry: field === "decisionRegistry" ? first.decisionRegistry : second.decisionRegistry, + readLedger: field === "readLedger" ? first.readLedger : second.readLedger, + }), (error: unknown) => error instanceof WorldRuntimeError && error.code === "world_runtime_invalid_composition"); + } + const crossRun = runtimeFixture(false, { runId: "run-2", worldInstanceId: "instance-2" }); + assert.throws(() => createWorldRuntime({ + dynamics: first.dynamics, surfaceRegistry: crossRun.surfaceRegistry, capabilityManifests: crossRun.capabilityManifests, + boundGrants: crossRun.boundGrants, decisionRegistry: crossRun.decisionRegistry, readLedger: first.readLedger, + }), (error: unknown) => error instanceof WorldRuntimeError && error.code === "world_runtime_invalid_composition"); + assert.equal(first.dynamicsCalls(), 0); +}); + +test("rejects preloaded records outside the current runtime identity without returning them", () => { + const fixture = runtimeFixture(false); + const writer = readWorldReadLedger(fixture.readLedger)!; + writer.append({ operation: "status", principal: "principal-red", result: "allowed", decision_id: "decision-000000000001", state_version: 0, + identity: { run_id: fixture.red.token, world_id: "pitch", world_instance_id: "instance-1", manifest_digest: `sha256:${"a".repeat(64)}`, state_version: 0 } }); + const runtime = createWorldRuntime({ dynamics: fixture.dynamics, surfaceRegistry: fixture.surfaceRegistry, capabilityManifests: fixture.capabilityManifests, boundGrants: fixture.boundGrants, decisionRegistry: fixture.decisionRegistry, readLedger: fixture.readLedger }); + assert.throws(() => runtime.ledger!({ principal: "principal-red", decisionToken: fixture.red.token }, {}), WorldRuntimeError); + assert.equal(fixture.dynamicsCalls(), 0); +}); + +test("reserves every manifest principal before unknown callers can consume capacity", () => { + const undersized = runtimeFixture(false, { + runId: "run-1", + worldInstanceId: "instance-1", + maxLedgerPrincipals: 1, + }); + assert.throws(() => createWorldRuntime({ + dynamics: undersized.dynamics, + surfaceRegistry: undersized.surfaceRegistry, + capabilityManifests: undersized.capabilityManifests, + boundGrants: undersized.boundGrants, + decisionRegistry: undersized.decisionRegistry, + readLedger: undersized.readLedger, + }), (error: unknown) => error instanceof WorldRuntimeError && error.code === "world_runtime_invalid_composition"); + const recovered = createWorldRuntime({ + dynamics: undersized.dynamics, + surfaceRegistry: undersized.surfaceRegistry, + capabilityManifests: undersized.capabilityManifests, + boundGrants: undersized.boundGrants, + decisionRegistry: undersized.decisionRegistry, + readLedger: createWorldReadLedger({ maxEntriesPerPrincipal: 20, maxPrincipals: 2 }), + }); + assert.equal(recovered.status({ + principal: "principal-red", + decisionToken: undersized.red.token, + }).orientation.holder_entity, "world://pitch/entity/red"); + + const fixture = runtimeFixture(false, { + runId: "run-1", + worldInstanceId: "instance-1", + maxLedgerPrincipals: 2, + }); + const runtime = createWorldRuntime({ + dynamics: fixture.dynamics, + surfaceRegistry: fixture.surfaceRegistry, + capabilityManifests: fixture.capabilityManifests, + boundGrants: fixture.boundGrants, + decisionRegistry: fixture.decisionRegistry, + readLedger: fixture.readLedger, + }); + assert.throws(() => runtime.status({ principal: "unknown", decisionToken: "not-a-token" }), WorldRuntimeError); + assert.equal(runtime.status({ principal: "principal-red", decisionToken: fixture.red.token }).orientation.holder_entity, "world://pitch/entity/red"); + assert.equal(runtime.status({ principal: "principal-blue", decisionToken: fixture.blue.token }).orientation.holder_entity, "world://pitch/entity/blue"); + assert.equal(fixture.readLedger.read("unknown", {}).records.length, 0); + assert.equal(fixture.readLedger.read("principal-red", {}).records.length, 1); + assert.equal(fixture.readLedger.read("principal-blue", {}).records.length, 1); +}); diff --git a/src/world/runtime.ts b/src/world/runtime.ts new file mode 100644 index 0000000..b8835b6 --- /dev/null +++ b/src/world/runtime.ts @@ -0,0 +1,395 @@ +import { isDeepStrictEqual, types } from "node:util"; +import { readCheckedDynamicsSession, type DynamicsSession } from "../dynamics/session.js"; +import type { WorldSurfaceRegistry } from "../world-surface/index.js"; +import { readParsedWorldSurfaceRegistry } from "../world-surface/definition.js"; +import { compileCapabilityManifests, parseCapabilityManifest, serializeCapabilityManifest, type CapabilityManifest, type CapabilityManifestArtifact } from "./capabilityManifest.js"; +import { readDecisionRegistry, type DecisionReadAdmission, type DecisionRegistry } from "./decisionRegistry.js"; +import { createDecisionResultReadAdmission } from "./decisionResultReadAdmission.js"; +import type { BoundWorldGrant } from "./grants.js"; +import { readBoundWorldGrants } from "./grantAttestation.js"; +import { parseWorldReadLedgerRecord, parseWorldReadLedgerRequest, readWorldReadLedger, WorldRuntimeError, type WorldReadIdentity, type WorldReadLedger, type WorldReadLedgerAuthority, type WorldReadLedgerPage, type WorldReadLedgerRecord, type WorldReadOperation } from "./ledger.js"; +import { observeWorldRuntime, type WorldRuntimeObservation } from "./observe.js"; +import { affordancesWorldRuntime, type WorldRuntimeAffordances } from "./affordances.js"; +import { actWorldRuntime } from "./act.js"; +import type { WorldActIngressReceipt } from "./actTypes.js"; +import { createWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import { createWorldRuntimeControllerAuthority } from "./controllerAuthority.js"; +import { registerWorldRuntimeActionJournalInspection, registerWorldRuntimeActionRefusalJournalInspection } from "./actionJournalInspection.js"; +import { createWorldActionRefusalJournal } from "./actionRefusalJournal.js"; +import { registerWorldRuntimeRequestLedgerInspection } from "./requestLedgerInspection.js"; +import { parseWorldActionResultPageRequest, readWorldActionResultLedger, type WorldActionResultPage } from "./actionResultLedger.js"; +import { registerWorldRuntimeActionResultLedgerInspection } from "./actionResultLedgerInspection.js"; +import { readWorldRuntimeCheckpointCoordinator, registerWorldRuntimeCheckpointCoordinator } from "./checkpointRuntime.js"; +import { createWorldRuntimePrivateStores, restoreWorldRuntimeCheckpoint } from "./checkpointRestore.js"; +import { createWorldDecisionClaimAuthority, registerWorldDecisionClaimAuthority } from "./decisionClaim.js"; +export interface AuthenticatedWorldContext { readonly principal: string; readonly decisionToken: string; } +/** The sole exact host-owned runtime construction contract. */ +export const WORLD_RUNTIME_INPUT_FIELDS = [ + "dynamics", "surfaceRegistry", "capabilityManifests", "boundGrants", "decisionRegistry", "readLedger", +] as const; +export interface CreateWorldRuntimeInput { + readonly dynamics: DynamicsSession; readonly surfaceRegistry: WorldSurfaceRegistry; + readonly capabilityManifests: readonly CapabilityManifestArtifact[]; readonly boundGrants: readonly BoundWorldGrant[]; + readonly decisionRegistry: DecisionRegistry; readonly readLedger: WorldReadLedger; +} +export interface WorldRuntimeIdentity extends WorldReadIdentity {} +export interface WorldRuntimeStatus { readonly identity: WorldRuntimeIdentity; readonly orientation: { readonly holder_entity: string }; readonly decision: { readonly id: string; readonly phase: "open" | "cutoff"; readonly issued_at_tick: number; readonly valid_through_tick: number }; } +export interface WorldRuntimeCapabilities { readonly identity: WorldRuntimeIdentity; readonly manifest: CapabilityManifest; } +export interface WorldRuntimeLedger { readonly identity: WorldRuntimeIdentity; readonly records: WorldReadLedgerPage["records"]; readonly next_after: number; } +export interface WorldRuntimeResultLedger { readonly identity: WorldRuntimeIdentity; readonly results: WorldActionResultPage["results"]; readonly next_result_after?: WorldActionResultPage["next_result_after"]; } +export interface WorldRuntime { status(context: AuthenticatedWorldContext): WorldRuntimeStatus; capabilities(context: AuthenticatedWorldContext): WorldRuntimeCapabilities; observe(context: AuthenticatedWorldContext, request: unknown): WorldRuntimeObservation; affordances(context: AuthenticatedWorldContext): WorldRuntimeAffordances; ledger(context: AuthenticatedWorldContext, request?: T): T extends { readonly version: "simfile.world-action-result-page-request.v1" } ? WorldRuntimeResultLedger : WorldRuntimeLedger; act(context: AuthenticatedWorldContext, envelope: Uint8Array): WorldActIngressReceipt; } +type CheckedContext = { readonly principal?: string; readonly token?: string }; +type Composition = { readonly runId: string; readonly worldId: string; readonly worldInstanceId: string; readonly manifests: ReadonlyMap }; +type Dependencies = Readonly<{ dynamics: DynamicsSession; surfaceRegistry: WorldSurfaceRegistry; capabilityManifests: readonly CapabilityManifestArtifact[]; decisionRegistry: DecisionRegistry; readLedger: WorldReadLedgerAuthority; ledgerHandle: WorldReadLedger }>; + +const claimedDynamics = new WeakSet(); +const claimedDecisionRegistries = new WeakSet(); +const claimedLedgers = new WeakSet(); +const stagedDynamics = new WeakMap(); +const stagedDecisionRegistries = new WeakMap(); +const stagedLedgers = new WeakMap(); +const denied = (): never => { throw new WorldRuntimeError("world_runtime_denied"); }; +const invalidComposition = (): never => { throw new WorldRuntimeError("world_runtime_invalid_composition"); }; +type WorldRuntimeOwnerLease = Readonly<{ available(): boolean; consume(): void; release(): void }>; +const stageWorldRuntimeOwners = (dependencies: Dependencies): WorldRuntimeOwnerLease => { + const token = {}; + if (claimedDynamics.has(dependencies.dynamics) || claimedDecisionRegistries.has(dependencies.decisionRegistry) + || claimedLedgers.has(dependencies.ledgerHandle) || stagedDynamics.has(dependencies.dynamics) + || stagedDecisionRegistries.has(dependencies.decisionRegistry) || stagedLedgers.has(dependencies.ledgerHandle)) { + return invalidComposition(); + } + stagedDynamics.set(dependencies.dynamics, token); + stagedDecisionRegistries.set(dependencies.decisionRegistry, token); + stagedLedgers.set(dependencies.ledgerHandle, token); + let active = true; + const owns = (): boolean => active && stagedDynamics.get(dependencies.dynamics) === token + && stagedDecisionRegistries.get(dependencies.decisionRegistry) === token + && stagedLedgers.get(dependencies.ledgerHandle) === token; + const release = (): void => { + if (!owns()) return; + stagedDynamics.delete(dependencies.dynamics); + stagedDecisionRegistries.delete(dependencies.decisionRegistry); + stagedLedgers.delete(dependencies.ledgerHandle); + active = false; + }; + return Object.freeze({ + available: (): boolean => owns() && !claimedDynamics.has(dependencies.dynamics) + && !claimedDecisionRegistries.has(dependencies.decisionRegistry) + && !claimedLedgers.has(dependencies.ledgerHandle), + consume: (): void => { + if (!owns() || claimedDynamics.has(dependencies.dynamics) + || claimedDecisionRegistries.has(dependencies.decisionRegistry) + || claimedLedgers.has(dependencies.ledgerHandle)) return invalidComposition(); + claimedDynamics.add(dependencies.dynamics); + claimedDecisionRegistries.add(dependencies.decisionRegistry); + claimedLedgers.add(dependencies.ledgerHandle); + // Every permanent marker exists before any staging marker is removed. + release(); + }, + release, + }); +}; +const isProxy = (value: unknown): boolean => value !== null && typeof value === "object" && types.isProxy(value as object); +const binding = (value: unknown): value is string => typeof value === "string" && value.length > 0 && value.length <= 256 && value === value.trim(); +const safeInteger = (value: unknown, minimum = 0): value is number => typeof value === "number" && Number.isSafeInteger(value) && value >= minimum; +const dataObject = (value: unknown, fields: readonly string[], exact = true): Record | undefined => { + if (value === null || typeof value !== "object" || Array.isArray(value) || isProxy(value) || Object.getPrototypeOf(value) !== Object.prototype) return undefined; + const keys = Reflect.ownKeys(value); + if ((exact && keys.length !== fields.length) || keys.some((key) => typeof key !== "string" || !fields.includes(key))) return undefined; + const output: Record = Object.create(null); + for (const key of keys) { + const descriptor = Object.getOwnPropertyDescriptor(value, key); + if (!descriptor?.enumerable || !("value" in descriptor)) return undefined; + output[key as string] = descriptor.value; + } + return output; +}; +const dataArray = (value: unknown): readonly unknown[] | undefined => { + if (!Array.isArray(value) || isProxy(value) || Object.getPrototypeOf(value) !== Array.prototype) return undefined; + const length = Object.getOwnPropertyDescriptor(value, "length"); + if (!length || !("value" in length) || !Number.isSafeInteger(length.value) || Reflect.ownKeys(value).length !== length.value + 1) return undefined; + const output: unknown[] = []; + for (let index = 0; index < length.value; index += 1) { + const descriptor = Object.getOwnPropertyDescriptor(value, String(index)); + if (!descriptor?.enumerable || !("value" in descriptor)) return undefined; + output.push(descriptor.value); + } + return output; +}; +const equalBytes = (left: readonly number[], right: readonly number[]): boolean => left.length === right.length && left.every((byte, index) => byte === right[index]); +const cloneManifest = (value: CapabilityManifest): CapabilityManifest => parseCapabilityManifest(serializeCapabilityManifest(value)); +const freezeIdentity = (manifest: CapabilityManifest, stateVersion: number): WorldRuntimeIdentity => Object.freeze({ run_id: manifest.run_id, world_id: manifest.world.id, world_instance_id: manifest.world.instance_id, manifest_digest: manifest.manifest_digest, state_version: stateVersion }); + +const safeContext = (input: unknown): CheckedContext => { + if (input === null || typeof input !== "object" || Array.isArray(input) || isProxy(input) || Object.getPrototypeOf(input) !== Object.prototype) return {}; + const principal = Object.getOwnPropertyDescriptor(input, "principal"); const token = Object.getOwnPropertyDescriptor(input, "decisionToken"); + const safePrincipal = principal?.enumerable && "value" in principal && binding(principal.value) ? principal.value : undefined; + const safeToken = token?.enumerable && "value" in token && typeof token.value === "string" ? token.value : undefined; + return Reflect.ownKeys(input).length === 2 ? { principal: safePrincipal, token: safeToken } : { principal: safePrincipal }; +}; +const checkedArtifacts = (input: unknown): readonly CapabilityManifestArtifact[] | undefined => { + const items = dataArray(input); if (items === undefined || items.length === 0) return undefined; + const output: CapabilityManifestArtifact[] = []; + for (const item of items) { + const artifact = dataObject(item, ["manifest", "bytes", "digest"]); const bytes = artifact === undefined ? undefined : dataArray(artifact.bytes); + if (artifact === undefined || bytes === undefined || !bytes.every((byte) => Number.isInteger(byte) && (byte as number) >= 0 && (byte as number) <= 255) || !binding(artifact.digest)) return undefined; + try { + const byBytes = parseCapabilityManifest(bytes as readonly number[]); const byValue = parseCapabilityManifest(serializeCapabilityManifest(artifact.manifest as CapabilityManifest)); + if (artifact.digest !== byBytes.manifest_digest || !equalBytes(bytes as readonly number[], serializeCapabilityManifest(byBytes)) || !equalBytes(serializeCapabilityManifest(byValue), serializeCapabilityManifest(byBytes))) return undefined; + output.push(Object.freeze({ manifest: cloneManifest(byBytes), bytes: Object.freeze([...bytes] as number[]), digest: byBytes.manifest_digest })); + } catch { return undefined; } + } + return Object.freeze(output); +}; +const compose = (input: unknown): { readonly dependencies: Dependencies; readonly composition: Composition } => { + const source = dataObject(input, WORLD_RUNTIME_INPUT_FIELDS); + if (source === undefined || readParsedWorldSurfaceRegistry(source.surfaceRegistry) === undefined) return invalidComposition(); + const dynamics = readCheckedDynamicsSession(source.dynamics); const decisionRegistry = readDecisionRegistry(source.decisionRegistry); const readLedger = readWorldReadLedger(source.readLedger); + const artifacts = checkedArtifacts(source.capabilityManifests); const grants = readBoundWorldGrants(source.boundGrants); + if (dynamics === undefined || decisionRegistry === undefined || readLedger === undefined || artifacts === undefined || grants === undefined) return invalidComposition(); + const first = artifacts[0]!.manifest; + let expected: readonly CapabilityManifestArtifact[]; + try { expected = compileCapabilityManifests({ runId: first.run_id, worldInstanceId: first.world.instance_id, world: { id: first.world.id as never }, surfaceRegistry: source.surfaceRegistry as WorldSurfaceRegistry, grants: grants as BoundWorldGrant[] }); } catch { return invalidComposition(); } + if (expected.length !== artifacts.length || expected.some((item, index) => !equalBytes(item.bytes, artifacts[index]!.bytes))) return invalidComposition(); + try { + const snapshot = dataObject(decisionRegistry.snapshot(), ["version", "runId", "worldInstanceId", "tokenDigestKeyFingerprint", "phase", "cutoffTick", "admissionsClosedTick", "finalizedTick", "lastTick", "nextDecisionSequence", "decisions"]); + if (snapshot === undefined || snapshot.runId !== first.run_id || snapshot.worldInstanceId !== first.world.instance_id) return invalidComposition(); + } catch { return invalidComposition(); } + if (!binding(first.run_id) || !binding(first.world.id) || !binding(first.world.instance_id) + || artifacts.some((artifact) => !binding(artifact.manifest.holder.principal) || artifact.manifest.run_id !== first.run_id + || artifact.manifest.world.id !== first.world.id || artifact.manifest.world.instance_id !== first.world.instance_id)) return invalidComposition(); + const manifests = new Map(artifacts.map((artifact) => [artifact.manifest.holder.principal, cloneManifest(artifact.manifest)])); + if (manifests.size !== artifacts.length) return invalidComposition(); + return { dependencies: Object.freeze({ dynamics, surfaceRegistry: source.surfaceRegistry as WorldSurfaceRegistry, capabilityManifests: artifacts, decisionRegistry, readLedger, ledgerHandle: source.readLedger as WorldReadLedger }), composition: Object.freeze({ runId: first.run_id, worldId: first.world.id, worldInstanceId: first.world.instance_id, manifests }) }; +}; + +const copyLedgerPage = (value: unknown, principal: string, request: ReturnType, currentRunId: string, currentWorldId: string, currentWorldInstanceId: string, currentManifestDigest: string): WorldReadLedgerPage | undefined => { + const page = dataObject(value, ["records", "next_after"]); const values = page === undefined ? undefined : dataArray(page.records); + if (page === undefined || values === undefined || !safeInteger(page.next_after)) return undefined; + const allowed = request.operations === undefined ? undefined : new Set(request.operations); let previous = request.after; const records: WorldReadLedgerRecord[] = []; + for (const value of values) { + const source = dataObject(value, ["sequence", "operation", "principal", "decision_id", "state_version", "result", "identity"], false); + if (source === undefined || !safeInteger(source.sequence, 1)) return undefined; + const record = parseWorldReadLedgerRecord({ + operation: source.operation, principal: source.principal, decision_id: source.decision_id, + state_version: source.state_version, result: source.result, identity: source.identity, + }); + if (record === undefined || record.principal !== principal || (record.result === "allowed" && (record.identity!.run_id !== currentRunId || record.identity!.world_id !== currentWorldId || record.identity!.world_instance_id !== currentWorldInstanceId || record.identity!.manifest_digest !== currentManifestDigest)) || source.sequence <= previous || source.sequence <= request.after || (allowed !== undefined && !allowed.has(record.operation))) return undefined; + previous = source.sequence; + records.push(Object.freeze({ ...record, sequence: source.sequence })); + } + if (records.length > request.limit || page.next_after !== (records.at(-1)?.sequence ?? request.after)) return undefined; + return Object.freeze({ records: Object.freeze(records), next_after: page.next_after }); +}; + +const resultRequest = (value: unknown): boolean => { + const source = dataObject(value, ["version", "limit", "result_after"], false); + return source?.version === "simfile.world-action-result-page-request.v1"; +}; +const buildWorldRuntime = (dependencies: Dependencies, composition: Composition, + checkpointInput: { readonly checkpoint: unknown } | undefined, ownerLease: WorldRuntimeOwnerLease): WorldRuntime => { + const available = ownerLease.available; + const consume = ownerLease.consume; + let restored: ReturnType | undefined; + if (checkpointInput !== undefined) { + try { + restored = restoreWorldRuntimeCheckpoint({ + checkpoint: checkpointInput.checkpoint, + dynamics: dependencies.dynamics, + capabilityManifests: dependencies.capabilityManifests, + decisionRegistry: dependencies.decisionRegistry, + readLedger: dependencies.ledgerHandle, + available, + consume, + }); + } catch { return invalidComposition(); } + } else if (!available()) return invalidComposition(); + const stores = restored ?? createWorldRuntimePrivateStores(); + const resultLedger = stores.actionResultLedger; + const resultAuthority = readWorldActionResultLedger(resultLedger); + if (resultAuthority === undefined) return invalidComposition(); + if (restored === undefined) try { + resultAuthority.reserve({ bindings: [...composition.manifests.values()].map((manifest) => ({ + principal: manifest.holder.principal, actor: manifest.holder.entity, run_id: manifest.run_id, + world_id: manifest.world.id, world_instance_id: manifest.world.instance_id, manifest_digest: manifest.manifest_digest, + })) }); + } catch { return invalidComposition(); } + if (restored === undefined) try { + dependencies.readLedger.reservePrincipals(Object.freeze([...composition.manifests.keys()])); + } catch { + return invalidComposition(); + } + const actionJournal = stores.actionJournal; + if (restored === undefined) try { actionJournal.reservePrincipals(Object.freeze([...composition.manifests.keys()])); } catch { return invalidComposition(); } + const actionRefusals = createWorldActionRefusalJournal({ principals: [...composition.manifests.keys()], readTick: () => dependencies.dynamics.nextTick }); + const requestLedger = stores.requestLedger; + const ledgerHandle = dependencies.ledgerHandle; + if (restored === undefined) consume(); + let operating = false; + let reentered = false; + let mechanicsClosed = restored?.mechanicsClosed ?? false; + let checkpointing = false; + const operation = Object.freeze({ + enter: (): void => { + if (mechanicsClosed) throw new Error("world mechanics closed"); + if (operating) { reentered = true; throw new Error("world runtime reentry"); } + operating = true; reentered = false; + }, + leave: (): void => { operating = false; reentered = false; }, + reentered: (): boolean => reentered, + close: (): void => { mechanicsClosed = true; requestLedger.close(); actionJournal.close(); }, + }); + const checkpointOperation = Object.freeze({ + enter: (): void => { + if (checkpointing) { reentered = true; throw new Error("world checkpoint reentry"); } + if (operating) throw new Error("world runtime is not stable"); + operating = true; reentered = false; checkpointing = true; + }, + leave: (): void => { checkpointing = false; operating = false; reentered = false; }, + stable: (): boolean => checkpointing && operating && !reentered, + }); + const claimAuthority = createWorldDecisionClaimAuthority({ decisionRegistry: dependencies.decisionRegistry, + principals: new Set(composition.manifests.keys()), readTick: () => dependencies.dynamics.nextTick }); + const call = (operation: WorldReadOperation, context: unknown, request: unknown, build: (manifest: CapabilityManifest, admission: DecisionReadAdmission, identity: WorldRuntimeIdentity) => Result): Result => { + if (operating) { + reentered = true; + const reentrant = safeContext(context); + if (!checkpointing && reentrant.principal !== undefined && composition.manifests.has(reentrant.principal)) { + try { dependencies.readLedger.append(Object.freeze({ operation, principal: reentrant.principal, result: "denied" })); } catch { /* The generic denial remains authoritative. */ } + } + return denied(); + } + operating = true; + let auditAttempted = false; + const checked = safeContext(context); + const audit = (record: Omit[0], "principal">): boolean => { + if (auditAttempted || checked.principal === undefined) return false; + auditAttempted = true; + try { dependencies.readLedger.append(Object.freeze({ ...record, principal: checked.principal })); return true; } catch { return false; } + }; + const reject = (): never => { if (checked.principal !== undefined) audit({ operation, result: "denied" }); return denied(); }; + try { + if (checked.principal === undefined || checked.token === undefined) return reject(); + const manifest = composition.manifests.get(checked.principal); if (manifest === undefined) return reject(); + let atTick: number; let admission: DecisionReadAdmission; + try { + atTick = dependencies.dynamics.nextTick; + if (!safeInteger(atTick)) return reject(); + admission = dependencies.decisionRegistry.peekReadAdmission({ principal: checked.principal, runId: composition.runId, worldInstanceId: composition.worldInstanceId, token: checked.token, atTick }); + } catch { return reject(); } + const identity = freezeIdentity(manifest, atTick); + let result: Result; + try { result = build(manifest, admission, identity); } catch { return reject(); } + if (!audit({ operation, decision_id: admission.decisionId, state_version: atTick, result: "allowed", identity })) return denied(); + return result; + } finally { operating = false; } + }; + const runtime = Object.freeze({ + status: (context: AuthenticatedWorldContext) => call("status", context, undefined, (manifest, admission, identity) => Object.freeze({ identity, orientation: Object.freeze({ holder_entity: manifest.holder.entity }), decision: Object.freeze({ id: admission.decisionId, phase: admission.phase, issued_at_tick: admission.issuedTick, valid_through_tick: admission.validThroughTick }) })), + capabilities: (context: AuthenticatedWorldContext) => call("capabilities", context, undefined, (manifest, _admission, identity) => Object.freeze({ identity, manifest: cloneManifest(manifest) })), + observe: (context: AuthenticatedWorldContext, request: unknown) => call("observe", context, request, (manifest, _admission, identity) => + observeWorldRuntime({ dynamics: dependencies.dynamics, surfaceRegistry: dependencies.surfaceRegistry }, manifest, identity, request)), + affordances: (context: AuthenticatedWorldContext) => call("affordances", context, undefined, (manifest, _admission, identity) => + affordancesWorldRuntime({ dynamics: dependencies.dynamics, surfaceRegistry: dependencies.surfaceRegistry }, manifest, identity)), + ledger: (context: AuthenticatedWorldContext, request?: unknown) => { + if (resultRequest(request)) { + if (operating) { reentered = true; return denied(); } + operating = true; + try { + const checked = safeContext(context); const manifest = checked.principal === undefined ? undefined : composition.manifests.get(checked.principal); + const parsed = parseWorldActionResultPageRequest(request); + if (checked.principal === undefined || checked.token === undefined || manifest === undefined || parsed === undefined) return denied(); + const atTick = dependencies.dynamics.nextTick; + if (!safeInteger(atTick)) return denied(); + const identity = freezeIdentity(manifest, atTick); + const issuer = createDecisionResultReadAdmission({ registry: dependencies.decisionRegistry, manifest, runtimeAuthority: identity }); + const admission = issuer.admit({ principal: checked.principal, runId: composition.runId, worldInstanceId: composition.worldInstanceId, token: checked.token, atTick, manifest, runtimeAuthority: identity }); + const metadata = issuer.read(admission); + if (metadata.principal !== checked.principal || metadata.runId !== composition.runId || metadata.worldInstanceId !== composition.worldInstanceId || metadata.atTick !== atTick || metadata.manifestDigest !== identity.manifest_digest) return denied(); + const page = resultLedger.read(checked.principal, parsed); + return Object.freeze({ identity, results: page.results, ...(page.next_result_after === undefined ? {} : { next_result_after: page.next_result_after }) }); + } catch { return denied(); } + finally { operating = false; } + } + return call("ledger", context, request, (_manifest, _admission, identity) => { + const checked = safeContext(context); const parsed = parseWorldReadLedgerRequest(request); const page = copyLedgerPage(dependencies.readLedger.read(checked.principal!, request), checked.principal!, parsed, composition.runId, composition.worldId, composition.worldInstanceId, identity.manifest_digest); + if (page === undefined) return denied(); + return Object.freeze({ identity, records: page.records, next_after: page.next_after }); + }); + }, + act: (context: AuthenticatedWorldContext, envelope: Uint8Array): WorldActIngressReceipt => { + const checked = safeContext(context); + if (operating) { + reentered = true; + if (!checkpointing && checked.principal !== undefined && composition.manifests.has(checked.principal)) { + try { + const audit = actionJournal.reserveAudit(checked.principal); + audit.commit("denied"); + if (audit.terminal_capacity) operation.close(); + } catch { operation.close(); } + } + return actionRefusals.refuse(checked.principal, "ingress_reentered"); + } + if (checked.principal === undefined || !composition.manifests.has(checked.principal)) return actionRefusals.refuse(checked.principal, "principal_unknown"); + if (mechanicsClosed) { + try { + const claim = requestLedger.beginClaim({ + bytes: envelope, + authority: { + principal: checked.principal, + run_id: composition.runId, + world_id: composition.worldId, + world_instance_id: composition.worldInstanceId, + }, + }); + if (claim.kind === "replay") return claim.receipt; + } catch { /* closed ingress remains denied */ } + return actionRefusals.refuse(checked.principal, "ingress_closed"); + } + operating = true; reentered = false; + try { + return actWorldRuntime({ dynamics: dependencies.dynamics, surfaceRegistry: dependencies.surfaceRegistry, decisionRegistry: dependencies.decisionRegistry, journal: actionJournal, requestLedger, runId: composition.runId, worldId: composition.worldId, worldInstanceId: composition.worldInstanceId, closeMechanics: operation.close, refuse: (reason, fieldPath) => actionRefusals.refuse(checked.principal, reason, fieldPath) }, composition.manifests.get(checked.principal)!, checked.principal, checked.token ?? "", envelope, () => reentered); + } finally { operating = false; reentered = false; } + }, + }); + registerWorldRuntimeActionJournalInspection(runtime, actionJournal); + registerWorldRuntimeActionRefusalJournalInspection(runtime, actionRefusals); + registerWorldRuntimeRequestLedgerInspection(runtime, requestLedger); + const controller = createWorldRuntimeControllerAuthority(runtime, { dynamics: dependencies.dynamics, operation }); + createWorldRuntimeClockAuthority(runtime, { controller, dynamics: dependencies.dynamics, + surfaceRegistry: dependencies.surfaceRegistry, journal: actionJournal, operation }); + registerWorldRuntimeActionResultLedgerInspection(runtime, resultLedger); + registerWorldRuntimeCheckpointCoordinator(runtime, { + dynamics: dependencies.dynamics, + capabilityManifests: dependencies.capabilityManifests, + decisionRegistry: dependencies.decisionRegistry, + actionJournal, + requestLedger, + actionResultLedger: resultLedger, + readLedger: ledgerHandle, + operation: checkpointOperation, + }); + registerWorldDecisionClaimAuthority(runtime, claimAuthority); + if (restored !== undefined) { + try { + const recaptured = readWorldRuntimeCheckpointCoordinator(runtime)?.capture(); + if (recaptured === undefined || !isDeepStrictEqual(recaptured, restored.checkpoint)) return invalidComposition(); + } catch { return invalidComposition(); } + } + return runtime as WorldRuntime; +}; + +const issueWorldRuntime = (input: unknown, checkpointInput?: { readonly checkpoint: unknown }): WorldRuntime => { + const { dependencies, composition } = compose(input); + const ownerLease = stageWorldRuntimeOwners(dependencies); + try { return buildWorldRuntime(dependencies, composition, checkpointInput, ownerLease); } + finally { ownerLease.release(); } +}; + +export const createWorldRuntime = (input: unknown): WorldRuntime => issueWorldRuntime(input); + +/** @internal Deep-only host restoration seam; deliberately absent from public barrels. */ +export const createRestoredWorldRuntime = ( + input: CreateWorldRuntimeInput, + checkpointInput: unknown, +): WorldRuntime => issueWorldRuntime(input, { checkpoint: checkpointInput }); diff --git a/src/world/runtimeActionResults.test.ts b/src/world/runtimeActionResults.test.ts new file mode 100644 index 0000000..f1becce --- /dev/null +++ b/src/world/runtimeActionResults.test.ts @@ -0,0 +1,397 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { WorldRuntimeError } from "./ledger.js"; +import { readWorldActionResultLedger } from "./actionResultLedger.js"; +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import { runtimeActionResultLedger, runtimeActionJournalSnapshot, runtimeActionJournalStatus, runtimeActEnvelope, runtimeFixtureWithHooks, runtimeRequestLedgerSnapshot } from "./runtime.test-helper.js"; +import * as worldBarrel from "./index.js"; +import * as packageBarrel from "../index.js"; + +const resultRequest: { readonly version: "simfile.world-action-result-page-request.v1"; readonly limit: number } = Object.freeze({ version: "simfile.world-action-result-page-request.v1", limit: 100 }); +const redContext = (token: string) => ({ principal: "principal-red", decisionToken: token }); +const blueContext = (token: string) => ({ principal: "principal-blue", decisionToken: token }); +const denied = (call: () => unknown, secrets: readonly string[] = []): void => assert.throws(call, (error: unknown) => { + if (!(error instanceof WorldRuntimeError) || error.code !== "world_runtime_denied") return false; + return secrets.every((secret) => !error.message.includes(secret)); +}); +const ingressDenied = (reason: "decision_token_consumed" | "ingress_reentered" | "ingress_closed") => Object.freeze({ + disposition: "rejected_at_ingress" as const, + code: "world_action_denied" as const, + reason, +}); +const stepForActions = (input: unknown) => ({ tick: (input as { readonly tick: number }).tick, events: [], action_results: (input as { readonly actions: readonly { readonly sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: true, sequence })) }); +const queue = (fixture: ReturnType, token: string, requestId: string, principal = "principal-red") => fixture.runtime!.act( + principal === "principal-red" ? redContext(token) : blueContext(token), + runtimeActEnvelope(requestId, principal === "principal-red" + ? { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } } + : { affordance: "world://pitch/affordance/wait", target: "world://pitch/entity/blue", input: { force: 1 } }), +); + +test("runtime-owned result reads follow the real checked session and terminal ordering", () => { + const fixture = runtimeFixtureWithHooks({ + step: (input) => { + const actions = (input as { readonly actions: readonly { readonly sequence: number }[] }).actions; + return { tick: 0, events: [{ cause_action_sequences: [actions[0]!.sequence], kind: "impact", payload: { strength: 1 }, source: "system:test", target: "object:ball" }], action_results: actions.map(({ sequence }) => ({ accepted: true, sequence })) }; + }, + }); + const runtime = fixture.runtime!; + const handle = runtimeActionResultLedger(runtime); + assert.ok(handle); + assert.throws(() => runtime.ledger(redContext(fixture.red.token), resultRequest), WorldRuntimeError); + const receipt = runtime.act(redContext(fixture.red.token), runtimeActEnvelope("runtime-result", { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } })); + assert.equal(receipt.disposition, "queued"); + assert.equal(fixture.dynamics.nextTick, 0); + const callsBeforeRead = fixture.dynamicsCalls(); + assert.equal(runtime.ledger(redContext(fixture.red.token), resultRequest).results.length, 0); + assert.equal(fixture.dynamicsCalls(), callsBeforeRead); + assert.deepEqual(runtime.ledger(redContext(fixture.red.token), { ...resultRequest, limit: 1 }), { identity: runtime.ledger(redContext(fixture.red.token), resultRequest).identity, results: [] }); + const clock = readWorldRuntimeClockAuthority(runtime)!; + assert.deepEqual(clock.stepDynamics(), { tick: 0, action_results: 1, events: 1 }); + const callsAfterStep = fixture.dynamicsCalls(); + const page = runtime.ledger(redContext(fixture.red.token), resultRequest); + assert.equal(page.results.length, 1); + const result = page.results[0]!; + assert.equal(result.receipt_id, receipt.receipt_id); + assert.equal(result.decision_id, receipt.decision_id); + assert.equal(result.action_sequence, 1); + assert.equal(result.result_id, "world-result-1"); + assert.equal(result.status, "applied"); + assert.deepEqual((result as Extract).caused_effect_ids, ["world-effect-1"]); + assert.equal(result.identity.state_version, 1); + assert.equal(Object.isFrozen(page), true); + assert.equal(Object.isFrozen(page.results), true); + assert.equal(Object.isFrozen(result), true); + assert.equal(Object.isFrozen(result.identity), true); + assert.ok(page.next_result_after && Object.isFrozen(page.next_result_after)); + assert.throws(() => (page.next_result_after as { after: number }).after = 99, TypeError); + assert.deepEqual(runtime.ledger(redContext(fixture.red.token), resultRequest), page, "public pages and cursor values are cloned/frozen"); + assert.deepEqual(runtimeActionJournalSnapshot(runtime)!.cells[0]!.terminal?.receipt_id, receipt.receipt_id); + assert.equal(fixture.dynamicsCalls(), callsAfterStep); +}); + +test("consumed admission is exclusive to the matching result page and replays read-only", () => { + const fixture = runtimeFixtureWithHooks({ step: (input) => ({ tick: 0, events: [], action_results: (input as { readonly actions: readonly { readonly sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: true, sequence })) }) }); + const runtime = fixture.runtime!; + const receipt = runtime.act(redContext(fixture.red.token), runtimeActEnvelope("route-matrix", { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } })); + assert.equal(receipt.disposition, "queued"); + const clock = readWorldRuntimeClockAuthority(runtime)!; + clock.stepDynamics(); + const first = runtime.ledger(redContext(fixture.red.token), resultRequest); + const dynamicsCalls = fixture.dynamicsCalls(); + for (const request of [undefined, {}, { limit: 100 }, { version: resultRequest.version, limit: 100, extra: true }]) { + assert.throws(() => runtime.ledger(redContext(fixture.red.token), request), WorldRuntimeError); + } + const auditAfterDeniedRoutes = fixture.readLedger.read("principal-red", {}); + assert.deepEqual(runtime.ledger(redContext(fixture.red.token), resultRequest), first); + assert.equal(fixture.dynamicsCalls(), dynamicsCalls); + assert.deepEqual(fixture.readLedger.read("principal-red", {}), auditAfterDeniedRoutes); + assert.throws(() => runtime.ledger({ principal: "principal-blue", decisionToken: fixture.blue.token }, resultRequest), WorldRuntimeError); + assert.throws(() => runtime.ledger({ principal: "principal-red", decisionToken: fixture.blue.token }, resultRequest), WorldRuntimeError); +}); + +test("consumed tokens cannot use any active-token operation or cross runtime result authority", () => { + const fixture = runtimeFixtureWithHooks({ step: (input) => ({ tick: 0, events: [], action_results: (input as { readonly actions: readonly { readonly sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: true, sequence })) }) }); + const runtime = fixture.runtime!; + const envelope = runtimeActEnvelope("exclusive-act", { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } }); + assert.equal(runtime.act(redContext(fixture.red.token), envelope).disposition, "queued"); + readWorldRuntimeClockAuthority(runtime)!.stepDynamics(); + for (const operation of [ + () => runtime.status(redContext(fixture.red.token)), + () => runtime.capabilities(redContext(fixture.red.token)), + () => runtime.observe(redContext(fixture.red.token), {}), + () => runtime.affordances(redContext(fixture.red.token)), + () => runtime.ledger(redContext(fixture.red.token), {}), + ]) assert.throws(operation, WorldRuntimeError); + assert.deepEqual(runtime.act(redContext(fixture.red.token), runtimeActEnvelope("different-act", { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 2 } })), ingressDenied("decision_token_consumed")); + + const other = runtimeFixtureWithHooks({}); + assert.throws(() => other.runtime!.ledger(redContext(fixture.red.token), resultRequest), WorldRuntimeError); +}); + +test("result route rejects hostile shapes and keeps private authority out of runtime values", () => { + const fixture = runtimeFixtureWithHooks({}); + const runtime = fixture.runtime!; + const handle = runtimeActionResultLedger(runtime)!; + assert.deepEqual(Reflect.ownKeys(runtime), ["status", "capabilities", "observe", "affordances", "ledger", "act"]); + assert.deepEqual(Reflect.ownKeys(handle), ["read"]); + assert.equal(Object.isFrozen(handle), true); + const trapped = { version: resultRequest.version, get limit() { throw new Error("getter"); } }; + assert.throws(() => runtime.ledger(redContext(fixture.red.token), trapped), WorldRuntimeError); + const proxy = new Proxy(resultRequest, { get: () => { throw new Error("proxy"); } }); + assert.throws(() => runtime.ledger(redContext(fixture.red.token), proxy), WorldRuntimeError); + assert.throws(() => runtime.ledger(redContext(fixture.red.token), { version: resultRequest.version, limit: 0 }), WorldRuntimeError); + assert.equal("reserveBatch" in runtime, false); + assert.equal("createWorldActionResultLedger" in runtime, false); + for (const name of ["reserve", "reserveBatch", "publish", "abort", "exportState", "importState", "secret", "key", "issuer", "admission", "store", "authority"]) { + assert.equal(name in runtime, false, name); + assert.equal(name in handle, false, name); + assert.equal(name in worldBarrel, false, name); + assert.equal(name in packageBarrel, false, name); + } +}); + +test("active and consumed decisions have the complete public route matrix without secret diagnostics", () => { + const fixture = runtimeFixtureWithHooks({ step: stepForActions }); + const runtime = fixture.runtime!; + const active = redContext(fixture.red.token); + const callsBefore = fixture.dynamicsCalls(); + assert.equal(runtime.status(active).decision.id, fixture.red.decisionId); + assert.equal(runtime.capabilities(active).manifest.holder.principal, "principal-red"); + assert.equal(runtime.observe(active, { sense: "world://pitch/sense/vision" }).sense, "world://pitch/sense/vision"); + assert.equal(runtime.affordances(active).affordances.length, 2); + assert.ok(runtime.ledger(active, {}).records.length >= 4); + denied(() => runtime.ledger(active, resultRequest), [fixture.red.token, "sha256:", "issuer", "registry", "key"]); + assert.equal(fixture.dynamicsCalls(), callsBefore + 3, "result and legacy-ledger reads add no mechanics calls"); + const receipt = queue(fixture, fixture.red.token, "matrix-consume"); + assert.equal(receipt.disposition, "queued"); + readWorldRuntimeClockAuthority(runtime)!.stepDynamics(); + const consumed = redContext(fixture.red.token); + for (const operation of [ + () => runtime.status(consumed), () => runtime.capabilities(consumed), + () => runtime.observe(consumed, { sense: "world://pitch/sense/vision" }), + () => runtime.affordances(consumed), () => runtime.ledger(consumed, {}), + ]) denied(operation, [fixture.red.token]); + assert.deepEqual(queue(fixture, fixture.red.token, "matrix-second"), ingressDenied("decision_token_consumed")); + const page = runtime.ledger(consumed, resultRequest); + assert.deepEqual(page.results.map((result) => result.receipt_id), [receipt.receipt_id]); +}); + +test("public result admission covers malformed, swapped, foreign, expired, and phase-bound decisions", () => { + const fixture = runtimeFixtureWithHooks({ step: stepForActions }); + const runtime = fixture.runtime!; + assert.equal(queue(fixture, fixture.red.token, "admission-open").disposition, "queued"); + readWorldRuntimeClockAuthority(runtime)!.stepDynamics(); + assert.equal(runtime.ledger(redContext(fixture.red.token), resultRequest).results.length, 1); + for (const context of [ + redContext("malformed"), redContext(fixture.blue.token), blueContext(fixture.red.token), + { principal: "principal-unknown", decisionToken: fixture.red.token }, + ]) denied(() => runtime.ledger(context, resultRequest), [fixture.red.token, fixture.blue.token, "sha256:", "issuer", "registry", "key"]); + let differentRunEntropy = 31; + const differentRun = runtimeFixtureWithHooks({ step: stepForActions, randomBytes: () => new Uint8Array(32).fill(differentRunEntropy++) }, true, { runId: "run-2", worldInstanceId: "instance-1" }); + denied(() => differentRun.runtime!.ledger(redContext(fixture.red.token), resultRequest), [fixture.red.token]); + let differentInstanceEntropy = 32; + const differentInstance = runtimeFixtureWithHooks({ step: stepForActions, randomBytes: () => new Uint8Array(32).fill(differentInstanceEntropy++) }, true, { runId: "run-1", worldInstanceId: "instance-2" }); + denied(() => differentInstance.runtime!.ledger(redContext(fixture.red.token), resultRequest), [fixture.red.token]); + + const cutoff = runtimeFixtureWithHooks({ step: stepForActions }); + assert.equal(queue(cutoff, cutoff.red.token, "admission-cutoff").disposition, "queued"); + readWorldRuntimeClockAuthority(cutoff.runtime!)!.stepDynamics(); + cutoff.decisionRegistry.beginCutoff(1); + assert.equal(cutoff.runtime!.ledger(redContext(cutoff.red.token), resultRequest).results.length, 1); + + const closed = runtimeFixtureWithHooks({ step: stepForActions }, true, { runId: "run-1", worldInstanceId: "instance-1", decisionValidThroughTick: 0 }); + assert.equal(queue(closed, closed.red.token, "admission-closed").disposition, "queued"); + const closedClock = readWorldRuntimeClockAuthority(closed.runtime!)!; + closedClock.stepDynamics(); + closed.decisionRegistry.beginCutoff(1); closed.decisionRegistry.closeAdmissions(1); + denied(() => closed.runtime!.ledger(redContext(closed.red.token), resultRequest), [closed.red.token]); + closed.decisionRegistry.finalize(1); + denied(() => closed.runtime!.ledger(redContext(closed.red.token), resultRequest), [closed.red.token]); + + const expired = runtimeFixtureWithHooks({ step: stepForActions }); + assert.equal(queue(expired, expired.red.token, "admission-expired").disposition, "queued"); + const expiredClock = readWorldRuntimeClockAuthority(expired.runtime!)!; + expiredClock.stepDynamics(); expiredClock.stepDynamics(); expiredClock.stepDynamics(); + denied(() => expired.runtime!.ledger(redContext(expired.red.token), resultRequest), [expired.red.token]); + // Manifest substitution is unreachable through the six-key runtime API; see + // decisionResultReadAdmission.test.ts "binding substitutions, cross-issued authorities, and hostile shapes fail closed". +}); + +test("result polling is byte-stable, state-pure, and denies cross-runtime cursors without mutation", () => { + const fixture = runtimeFixtureWithHooks({ step: stepForActions }); + assert.equal(queue(fixture, fixture.red.token, "poll-snapshot").disposition, "queued"); + readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + const runtime = fixture.runtime!; + const before = { + decision: fixture.decisionRegistry.snapshot(), journal: runtimeActionJournalSnapshot(runtime), + request: runtimeRequestLedgerSnapshot(runtime), audit: fixture.readLedger.read("principal-red", {}), + page: runtime.ledger(redContext(fixture.red.token), resultRequest), calls: fixture.dynamicsCalls(), + nextTick: fixture.dynamics.nextTick, pending: structuredClone(fixture.dynamics.snapshot().pending_actions), + hostPage: runtimeActionResultLedger(runtime)!.read("principal-red", resultRequest), + }; + const unchanged = () => { + assert.deepEqual(runtime.ledger(redContext(fixture.red.token), resultRequest), before.page); + assert.deepEqual(fixture.decisionRegistry.snapshot(), before.decision); + assert.deepEqual(runtimeActionJournalSnapshot(runtime), before.journal); + assert.deepEqual(runtimeRequestLedgerSnapshot(runtime), before.request); + assert.deepEqual(fixture.readLedger.read("principal-red", {}), before.audit); + assert.equal(fixture.dynamicsCalls(), before.calls); + assert.equal(fixture.dynamics.nextTick, before.nextTick); + assert.deepEqual(fixture.dynamics.snapshot().pending_actions, before.pending); + assert.deepEqual(runtimeActionResultLedger(runtime)!.read("principal-red", resultRequest), before.hostPage); + assert.equal(runtimeActionResultLedger(runtime)!.read("principal-red", resultRequest).results.length, before.hostPage.results.length); + }; + unchanged(); unchanged(); + const foreign = runtimeFixtureWithHooks({ step: stepForActions }, true, { runId: "run-foreign", worldInstanceId: "instance-foreign" }); + denied(() => foreign.runtime!.ledger(redContext(fixture.red.token), { ...resultRequest, result_after: before.page.next_result_after! }), [fixture.red.token]); + unchanged(); + let targetEntropy = 41; + const target = runtimeFixtureWithHooks({ step: stepForActions, randomBytes: () => new Uint8Array(32).fill(targetEntropy++) }, true, { runId: "run-target", worldInstanceId: "instance-target" }); + assert.equal(queue(target, target.red.token, "target-result").disposition, "queued"); + readWorldRuntimeClockAuthority(target.runtime!)!.stepDynamics(); + const targetBefore = { + decision: target.decisionRegistry.snapshot(), journal: runtimeActionJournalSnapshot(target.runtime!), + request: runtimeRequestLedgerSnapshot(target.runtime!), audit: target.readLedger.read("principal-red", {}), + dynamics: target.dynamics.nextTick, pending: structuredClone(target.dynamics.snapshot().pending_actions), + page: runtimeActionResultLedger(target.runtime!)!.read("principal-red", resultRequest), + }; + denied(() => target.runtime!.ledger(redContext(target.red.token), { ...resultRequest, result_after: before.page.next_result_after! }), [target.red.token]); + assert.deepEqual(target.decisionRegistry.snapshot(), targetBefore.decision); + assert.deepEqual(runtimeActionJournalSnapshot(target.runtime!), targetBefore.journal); + assert.deepEqual(runtimeRequestLedgerSnapshot(target.runtime!), targetBefore.request); + assert.deepEqual(target.readLedger.read("principal-red", {}), targetBefore.audit); + assert.equal(target.dynamics.nextTick, targetBefore.dynamics); + assert.deepEqual(target.dynamics.snapshot().pending_actions, targetBefore.pending); + assert.deepEqual(runtimeActionResultLedger(target.runtime!)!.read("principal-red", resultRequest), targetBefore.page); +}); + +test("public result cursors continue only for their issued principal and reject hostile descriptor inputs", () => { + const fixture = runtimeFixtureWithHooks({ step: stepForActions }, true, { runId: "run-1", worldInstanceId: "instance-1", decisionValidThroughTick: 4 }); + const runtime = fixture.runtime!; + assert.equal(queue(fixture, fixture.red.token, "cursor-one").disposition, "queued"); + const clock = readWorldRuntimeClockAuthority(runtime)!; clock.stepDynamics(); + const first = runtime.ledger(redContext(fixture.red.token), { ...resultRequest, limit: 1 }); + const secondToken = fixture.decisionRegistry.mint({ principal: "principal-red", issuedTick: 1, validThroughTick: 3 }); + assert.equal(queue(fixture, secondToken.token, "cursor-two").disposition, "queued"); clock.stepDynamics(); + const second = runtime.ledger(redContext(fixture.red.token), { ...resultRequest, limit: 1, result_after: first.next_result_after }); + assert.equal(second.results[0]!.receipt_id, "world-act-2"); + const tail = runtime.ledger(redContext(fixture.red.token), { ...resultRequest, limit: 1, result_after: second.next_result_after }); + assert.deepEqual(tail.results, []); assert.deepEqual(tail.next_result_after, second.next_result_after); + denied(() => runtime.ledger(redContext(fixture.red.token), { after: first.next_result_after }), [fixture.red.token]); + denied(() => runtime.ledger(redContext(fixture.red.token), { ...resultRequest, result_after: 1 }), [fixture.red.token]); + const blueToken = fixture.blue; + assert.equal(queue(fixture, blueToken.token, "cursor-blue", "principal-blue").disposition, "queued"); clock.stepDynamics(); + const blue = runtime.ledger(blueContext(blueToken.token), resultRequest); + denied(() => runtime.ledger(blueContext(blueToken.token), { ...resultRequest, result_after: first.next_result_after }), [blueToken.token]); + denied(() => runtime.ledger(redContext(secondToken.token), { ...resultRequest, result_after: blue.next_result_after }), [secondToken.token]); + let traps = 0; + const accessor: Record = { version: resultRequest.version }; + Object.defineProperty(accessor, "limit", { enumerable: true, get: () => { traps += 1; return 1; } }); + const nested: Record = { ...first.next_result_after! }; + Object.defineProperty(nested, "proof", { enumerable: true, get: () => { traps += 1; return "0".repeat(64); } }); + const proxy = new Proxy({ ...resultRequest }, { get: () => { traps += 1; throw new Error("trap"); } }); + const nestedProxy = new Proxy({ ...first.next_result_after! }, { get: () => { traps += 1; throw new Error("trap"); } }); + for (const request of [accessor, { ...resultRequest, extra: true }, proxy, { ...resultRequest, result_after: nested }, { ...resultRequest, result_after: nestedProxy }]) denied(() => runtime.ledger(redContext(secondToken.token), request), [secondToken.token]); + assert.equal(traps, 0); + const legacyActive = fixture.decisionRegistry.mint({ principal: "principal-red", issuedTick: 3, validThroughTick: 4 }); + const legacyPage = runtime.ledger(redContext(legacyActive.token), { limit: 100 }); + assert.ok(legacyPage.next_after > 0); + const legacyContinuation = runtime.ledger(redContext(legacyActive.token), { after: legacyPage.next_after, limit: 100 }); + assert.equal(legacyContinuation.identity.manifest_digest, legacyPage.identity.manifest_digest); + assert.equal(legacyContinuation.records.at(-1)?.operation, "ledger"); + assert.equal(legacyContinuation.next_after, legacyContinuation.records.at(-1)?.sequence); + const consumed = runtime.ledger(redContext(secondToken.token), resultRequest); + assert.ok(consumed.next_result_after); + denied(() => runtime.ledger(redContext(secondToken.token), { ...resultRequest, result_after: legacyPage.next_after }), [secondToken.token]); + const schemaFixture = runtimeFixtureWithHooks({}); + denied(() => schemaFixture.runtime!.ledger(redContext(schemaFixture.red.token), { after: consumed.next_result_after, limit: 100 }), [schemaFixture.red.token]); + // C2 covers only stale/evicted cursor frontiers in actionResultLedger.test.ts + // "uses principal paging sequences and fails only after an unseen same-principal eviction". +}); + +test("public red and blue result pages isolate identities while legitimately sharing one effect", () => { + const fixture = runtimeFixtureWithHooks({ step: (input) => { + const actions = (input as { readonly actions: readonly { readonly sequence: number }[] }).actions; + return { tick: 0, events: [{ cause_action_sequences: actions.map((action) => action.sequence), kind: "impact", payload: { strength: 1 }, source: "system:test", target: "object:ball" }], action_results: actions.map(({ sequence }) => ({ accepted: true, sequence })) }; + } }); + assert.equal(queue(fixture, fixture.red.token, "shared-red").disposition, "queued"); + assert.equal(queue(fixture, fixture.blue.token, "shared-blue", "principal-blue").disposition, "queued"); + readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + const red = fixture.runtime!.ledger(redContext(fixture.red.token), resultRequest); + const blue = fixture.runtime!.ledger(blueContext(fixture.blue.token), resultRequest); + assert.deepEqual(red.results.map((result) => [result.receipt_id, result.decision_id, result.result_id, result.action_sequence]), [["world-act-1", "decision-000000000001", "world-result-1", 1]]); + assert.deepEqual(blue.results.map((result) => [result.receipt_id, result.decision_id, result.result_id, result.action_sequence]), [["world-act-2", "decision-000000000002", "world-result-2", 2]]); + const redResult = red.results[0]!, blueResult = blue.results[0]!; + assert.deepEqual(runtimeActionJournalSnapshot(fixture.runtime!)!.cells.map((cell) => cell.record.principal), ["principal-red", "principal-blue"]); + assert.equal(redResult.actor, "world://pitch/entity/red"); assert.equal(blueResult.actor, "world://pitch/entity/blue"); + assert.deepEqual(redResult.identity, { run_id: "run-1", world_id: "pitch", world_instance_id: "instance-1", manifest_digest: redResult.identity.manifest_digest, state_version: 1 }); + assert.deepEqual(blueResult.identity, { run_id: "run-1", world_id: "pitch", world_instance_id: "instance-1", manifest_digest: blueResult.identity.manifest_digest, state_version: 1 }); + assert.deepEqual((redResult as Extract).caused_effect_ids, ["world-effect-1"]); + assert.deepEqual((blueResult as Extract).caused_effect_ids, ["world-effect-1"]); + assert.deepEqual(red.results.map((result) => result.receipt_id), ["world-act-1"]); + assert.deepEqual(blue.results.map((result) => result.receipt_id), ["world-act-2"]); + denied(() => fixture.runtime!.ledger(redContext(fixture.red.token), { ...resultRequest, result_after: blue.next_result_after }), [fixture.red.token]); +}); + +test("public rejected results omit effects and result reads cannot reenter or mutate legacy state", () => { + let reentry: unknown; + const reentrant = runtimeFixtureWithHooks({ + lower: () => { try { reentry = reentrant.runtime!.ledger(redContext(reentrant.red.token), resultRequest); } catch (error) { reentry = error; } return { force: 1 }; }, + }); + assert.deepEqual(queue(reentrant, reentrant.red.token, "reentry-denied"), ingressDenied("ingress_reentered")); + assert.ok(reentry instanceof WorldRuntimeError && reentry.code === "world_runtime_denied"); + assert.equal(runtimeActionJournalSnapshot(reentrant.runtime!)!.cells.length, 0); + assert.equal(runtimeActionResultLedger(reentrant.runtime!)!.read("principal-red", resultRequest).results.length, 0); + + const fixture = runtimeFixtureWithHooks({ + step: (input) => ({ tick: 0, events: [], action_results: (input as { readonly actions: readonly { readonly sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: false, code: "blocked", sequence })) }), + }); + const before = { audit: fixture.readLedger.read("principal-red", {}), request: runtimeRequestLedgerSnapshot(fixture.runtime!), journal: runtimeActionJournalSnapshot(fixture.runtime!), decision: fixture.decisionRegistry.snapshot() }; + assert.equal(queue(fixture, fixture.red.token, "rejected-reentry").disposition, "queued"); + assert.ok(reentry instanceof WorldRuntimeError && reentry.code === "world_runtime_denied"); + readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + const page = fixture.runtime!.ledger(redContext(fixture.red.token), resultRequest); + assert.equal(page.results[0]!.status, "rejected_at_mechanics"); + if (page.results[0]!.status === "rejected_at_mechanics") assert.equal(page.results[0]!.rejection_code, "blocked"); + assert.equal("caused_effect_ids" in page.results[0]!, false); + const afterAct = { audit: fixture.readLedger.read("principal-red", {}), request: runtimeRequestLedgerSnapshot(fixture.runtime!), journal: runtimeActionJournalSnapshot(fixture.runtime!), decision: fixture.decisionRegistry.snapshot(), calls: fixture.dynamicsCalls() }; + assert.deepEqual(fixture.runtime!.ledger(redContext(fixture.red.token), resultRequest), page); + assert.deepEqual({ audit: fixture.readLedger.read("principal-red", {}), request: runtimeRequestLedgerSnapshot(fixture.runtime!), journal: runtimeActionJournalSnapshot(fixture.runtime!), decision: fixture.decisionRegistry.snapshot(), calls: fixture.dynamicsCalls() }, afterAct); + assert.notDeepEqual(afterAct.request, before.request); + // Complete malformed/foreign/duplicate/missing terminal matrices remain in + // actionResults.test.ts "closes every impossible join with authorized pending evidence and no terminal facts" + // and worldActionMechanicsRealSession.test.ts "real result-mode checked validation...". +}); + +test("public undeclared provider rejection is fixed and secret-free", () => { + const fixture = runtimeFixtureWithHooks({ step: (input) => ({ + tick: 0, events: [], action_results: (input as { readonly actions: readonly { readonly sequence: number }[] }).actions.map(({ sequence }) => ({ accepted: false, code: "provider-secret", sequence })), + }) }); + const receipt = queue(fixture, fixture.red.token, "undeclared-provider-rejection"); + if (receipt.disposition !== "queued") throw new Error("expected queued receipt"); + assert.equal(readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics().action_results, 1); + const page = fixture.runtime!.ledger(redContext(fixture.red.token), resultRequest); + assert.equal(page.results.length, 1); + const result = page.results[0]!; + assert.equal(result.status, "rejected_at_mechanics"); + if (result.status === "rejected_at_mechanics") assert.equal(result.rejection_code, "world_action_rejected"); + assert.equal("caused_effect_ids" in result, false); + assert.equal(JSON.stringify(page).includes("provider-secret"), false); + assert.deepEqual(runtimeActionJournalSnapshot(fixture.runtime!)!.cells[0]!.terminal, { + disposition: "rejected_at_mechanics", receipt_id: receipt.receipt_id, decision_id: receipt.decision_id, + sequence: 1, apply_tick: 0, projection: "not_configured", public_code: "world_action_rejected", + }); +}); + +test("public malformed terminal join fails closed after real act and forbids retry", () => { + const fixture = runtimeFixtureWithHooks({ step: (input) => ({ + tick: 0, events: [], action_results: [{ accepted: true, sequence: (input as { readonly actions: readonly { readonly sequence: number }[] }).actions[0]!.sequence + 1 }], + }) }); + const originalEnvelope = runtimeActEnvelope("malformed-public-join", { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } }); + const receipt = fixture.runtime!.act(redContext(fixture.red.token), originalEnvelope); + if (receipt.disposition !== "queued") throw new Error("expected queued receipt"); + const clock = readWorldRuntimeClockAuthority(fixture.runtime!)!; + assert.throws(() => clock.stepDynamics()); + assert.equal(fixture.dynamics.nextTick, 0); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 1); + assert.deepEqual(fixture.runtime!.ledger(redContext(fixture.red.token), resultRequest).results, []); + assert.deepEqual(runtimeActionResultLedger(fixture.runtime!)!.read("principal-red", resultRequest).results, []); + assert.equal(runtimeActionJournalStatus(fixture.runtime!)!.closed, true); + assert.equal(runtimeRequestLedgerSnapshot(fixture.runtime!)!.closed, true); + assert.deepEqual(runtimeActionJournalSnapshot(fixture.runtime!)!.cells.map((cell) => [cell.receipt.receipt_id, cell.state, cell.terminal]), [[receipt.receipt_id, "authorized", null]]); + assert.equal(readWorldActionResultLedger(runtimeActionResultLedger(fixture.runtime!)!)!.hasLiveReservation(), false); + assert.throws(() => clock.stepDynamics()); + assert.deepEqual(queue(fixture, fixture.red.token, "malformed-public-retry"), ingressDenied("ingress_closed")); + const beforeReplay = { + dynamics: fixture.dynamics.snapshot(), journal: runtimeActionJournalSnapshot(fixture.runtime!), request: runtimeRequestLedgerSnapshot(fixture.runtime!), + page: fixture.runtime!.ledger(redContext(fixture.red.token), resultRequest), results: runtimeActionResultLedger(fixture.runtime!)!.read("principal-red", resultRequest), calls: fixture.dynamicsCalls(), + }; + assert.deepEqual(fixture.runtime!.act(redContext(fixture.red.token), originalEnvelope), receipt); + assert.deepEqual(fixture.dynamics.snapshot(), beforeReplay.dynamics); + assert.deepEqual(runtimeActionJournalSnapshot(fixture.runtime!), beforeReplay.journal); + assert.deepEqual(runtimeRequestLedgerSnapshot(fixture.runtime!), beforeReplay.request); + assert.deepEqual(fixture.runtime!.ledger(redContext(fixture.red.token), resultRequest), beforeReplay.page); + assert.deepEqual(runtimeActionResultLedger(fixture.runtime!)!.read("principal-red", resultRequest), beforeReplay.results); + assert.equal(fixture.dynamicsCalls(), beforeReplay.calls); +}); diff --git a/src/world/runtimeComposition.ts b/src/world/runtimeComposition.ts new file mode 100644 index 0000000..1e459bb --- /dev/null +++ b/src/world/runtimeComposition.ts @@ -0,0 +1,49 @@ +import { randomBytes } from "node:crypto"; + +import type { DynamicsSession } from "../dynamics/session.js"; +import type { SimfileWorld } from "../schema/model.js"; +import type { WorldSurfaceRegistry } from "../world-surface/index.js"; +import { composeWorldGrants } from "./grantComposition.js"; +import type { WorldGrantPrincipalResolver } from "./grants.js"; +import { createDecisionRegistry } from "./decisionRegistry.js"; +import { createWorldReadLedger } from "./ledger.js"; +import type { CreateWorldRuntimeInput } from "./runtime.js"; + +export interface ComposeWorldRuntimeInput { + readonly runId: string; + readonly principalResolver?: WorldGrantPrincipalResolver; + readonly worldInstanceId: string; + readonly world: SimfileWorld; + readonly surfaceRegistry: WorldSurfaceRegistry; + readonly session: DynamicsSession; + readonly maxEntriesPerPrincipal?: number; + readonly maxPrincipals?: number; +} + +/** Builds the complete six-field host-owned runtime composition. */ +export const composeWorldRuntimeInput = ( + input: ComposeWorldRuntimeInput +): CreateWorldRuntimeInput => { + const grants = composeWorldGrants({ + runId: input.runId, + principalResolver: input.principalResolver, + surfaceRegistry: input.surfaceRegistry, + world: input.world, + worldInstanceId: input.worldInstanceId, + }); + return Object.freeze({ + dynamics: input.session, + surfaceRegistry: input.surfaceRegistry, + capabilityManifests: grants.artifacts, + boundGrants: grants.boundGrants, + decisionRegistry: createDecisionRegistry({ + runId: input.runId, + worldInstanceId: input.worldInstanceId, + tokenDigestKey: randomBytes(32), + }), + readLedger: createWorldReadLedger({ + maxEntriesPerPrincipal: input.maxEntriesPerPrincipal, + maxPrincipals: input.maxPrincipals, + }), + }); +}; diff --git a/src/world/worldActionCallbacksRealSession.test.ts b/src/world/worldActionCallbacksRealSession.test.ts new file mode 100644 index 0000000..994589a --- /dev/null +++ b/src/world/worldActionCallbacksRealSession.test.ts @@ -0,0 +1,264 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DynamicsSession } from "../dynamics/session.js"; +import { runtimeActionJournalSnapshot, runtimeActEnvelope, runtimeFixtureWithHooks } from "./runtime.test-helper.js"; +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import type { WorldActIngressRejectionReason } from "./actTypes.js"; + +const denied = (reason: WorldActIngressRejectionReason, fieldPath?: string) => Object.freeze({ + disposition: "rejected_at_ingress" as const, + code: "world_action_denied" as const, + reason, + ...(fieldPath === undefined ? {} : { field_path: fieldPath }), +}); +const request = { affordance: "world://pitch/affordance/kick", target: "world://pitch/entity/ball", input: { force: 1 } }; +const context = (token: string) => ({ principal: "principal-red", decisionToken: token }); +const envelope = (requestId: string) => runtimeActEnvelope(requestId, request); +const mechanicsObservation = (input?: unknown) => { + const addresses = input !== null && typeof input === "object" && Array.isArray((input as { sense_addresses?: unknown }).sense_addresses) + ? (input as { sense_addresses: readonly unknown[] }).sense_addresses.filter((address): address is string => typeof address === "string") + : ["sense:state"]; + return { channels: addresses.map((sense_address) => ({ components: { x: 1 }, sense_address, subject_address: "object:red" })) }; +}; +const projectedObservation = (input: { readonly observation: { readonly channels: readonly { readonly components: Readonly>; readonly unit?: string }[] } }) => ({ + channels: input.observation.channels.map((channel) => ({ components: channel.components, sense_address: "sense:vision", subject_address: "entity:red", ...(channel.unit === undefined ? {} : { unit: channel.unit }) })), +}); +const probe = (id: string) => ({ act_id: id, action: "probe", actor: "object:red", at_tick: 0, input: {}, origin: "agentic", principal_id: "principal-red", target: "object:red" }); + +type Fixture = ReturnType; +type Behavior = (fixture: Fixture, dynamics: DynamicsSession, nestedResults: unknown[]) => unknown; + +const assertDeniedAndRestored = ( + fixture: Fixture, + reason: WorldActIngressRejectionReason, + fieldPath?: string, + name?: string, +): void => { + const beforeDynamics = fixture.dynamics.snapshot(); + const beforeDecision = fixture.decisionRegistry.snapshot(); + assert.deepEqual(fixture.runtime!.act(context(fixture.red.token), envelope("denied-action")), denied(reason, fieldPath), name); + assert.equal(fixture.dynamics.nextTick, beforeDynamics.next_tick); + assert.deepEqual(fixture.dynamics.snapshot(), beforeDynamics); + assert.deepEqual(fixture.decisionRegistry.snapshot(), beforeDecision); + const journal = runtimeActionJournalSnapshot(fixture.runtime); + assert.ok(journal); + assert.deepEqual(journal.cells, []); + assert.ok(journal.audits.length >= 1); + assert.ok(journal.audits.every((audit) => audit.principal === "principal-red" && audit.result === "denied")); +}; + +const queueAfterDenial = (fixture: Fixture): void => { + const receipt = fixture.runtime!.act(context(fixture.red.token), envelope("queued-after-denial")); + assert.equal(receipt.disposition, "queued"); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 1); + assert.equal(runtimeActionJournalSnapshot(fixture.runtime)!.cells.length, 1); +}; + +const observeCases: readonly { readonly name: string; readonly expectedCalls: number; readonly reason: WorldActIngressRejectionReason; readonly behavior: Behavior }[] = [ + { name: "provider throws", expectedCalls: 1, reason: "world_surface_failed", behavior: () => { throw new Error("observe"); } }, + { name: "provider returns a thenable", expectedCalls: 1, reason: "world_surface_failed", behavior: () => ({ then: () => {} }) }, + { name: "provider returns malformed observation", expectedCalls: 1, reason: "world_surface_failed", behavior: () => ({}) }, + { + name: "provider queues a real action and is restored", expectedCalls: 1, reason: "world_surface_failed", + behavior: (_fixture, dynamics) => { dynamics.queueAction(probe("observe-mutation")); return mechanicsObservation(); }, + }, + { + name: "provider advances the real tick and is restored", expectedCalls: 1, reason: "world_surface_failed", + behavior: (_fixture, dynamics) => { dynamics.step(); return mechanicsObservation(); }, + }, + { + name: "caught nested act reentry", expectedCalls: 1, reason: "world_surface_failed", + behavior: (fixture, _dynamics, nestedResults) => { nestedResults.push(fixture.runtime!.act(context(fixture.red.token), envelope("nested-observe"))); return mechanicsObservation(); }, + }, + { + name: "uncaught nested act reentry", expectedCalls: 1, reason: "world_surface_failed", + behavior: (fixture, _dynamics, nestedResults) => { nestedResults.push(fixture.runtime!.act(context(fixture.red.token), envelope("nested-observe-uncaught"))); throw new Error("uncaught reentry"); }, + }, +]; + +test("act aggregates hostile provider observations through the real session", () => { + for (const item of observeCases) { + let first = true; + let calls = 0; + const nestedResults: unknown[] = []; + let fixture!: Fixture; + fixture = runtimeFixtureWithHooks({ + observe: (input) => { + calls += 1; + if (first) { first = false; return item.behavior(fixture, fixture.dynamics, nestedResults); } + return mechanicsObservation(input); + }, + }); + assertDeniedAndRestored(fixture, item.reason, undefined, item.name); + assert.equal(calls, item.expectedCalls, item.name); + if (item.name.includes("nested act")) { + assert.equal(nestedResults.length, 1, item.name); + assert.deepEqual(nestedResults[0], denied("ingress_reentered")); + } + queueAfterDenial(fixture); + } +}); + +const projectionCases: readonly { readonly name: string; readonly expectedCalls: number; readonly reason: WorldActIngressRejectionReason; readonly behavior: Behavior }[] = [ + { name: "projection throws", expectedCalls: 1, reason: "world_surface_failed", behavior: () => { throw new Error("projection"); } }, + { name: "projection returns a thenable", expectedCalls: 1, reason: "world_surface_failed", behavior: () => ({ then: () => {} }) }, + { name: "projection returns malformed output", expectedCalls: 1, reason: "world_surface_failed", behavior: () => ({}) }, + { + name: "projection queues a real action and is restored", expectedCalls: 1, reason: "world_surface_failed", + behavior: (_fixture, dynamics) => { dynamics.queueAction(probe("projection-mutation")); return undefined; }, + }, + { + name: "projection advances the real tick and is restored", expectedCalls: 1, reason: "world_surface_failed", + behavior: (_fixture, dynamics) => { dynamics.step(); return undefined; }, + }, + { + name: "caught nested act reentry", expectedCalls: 1, reason: "ingress_reentered", + behavior: (fixture, _dynamics, nestedResults) => { nestedResults.push(fixture.runtime!.act(context(fixture.red.token), envelope("nested-project"))); return undefined; }, + }, + { + name: "uncaught nested act reentry", expectedCalls: 1, reason: "world_surface_failed", + behavior: (fixture, _dynamics, nestedResults) => { nestedResults.push(fixture.runtime!.act(context(fixture.red.token), envelope("nested-project-uncaught"))); throw new Error("uncaught reentry"); }, + }, +]; + +test("act uses checked sense projections before admission", () => { + for (const item of projectionCases) { + let first = true; + let calls = 0; + const nestedResults: unknown[] = []; + let fixture!: Fixture; + fixture = runtimeFixtureWithHooks({ + project: (input) => { + calls += 1; + if (first) { first = false; const result = item.behavior(fixture, fixture.dynamics, nestedResults); if (result !== undefined) return result; } + return projectedObservation(input); + }, + }); + assertDeniedAndRestored(fixture, item.reason, undefined, item.name); + assert.equal(calls, item.expectedCalls, item.name); + if (item.name.includes("nested act")) { + assert.equal(nestedResults.length, 1, item.name); + assert.deepEqual(nestedResults[0], denied("ingress_reentered")); + } + queueAfterDenial(fixture); + } +}); + +const availabilityCases: readonly { readonly name: string; readonly reason: WorldActIngressRejectionReason; readonly behavior: Behavior }[] = [ + { name: "false", reason: "affordance_unavailable", behavior: () => false }, + { name: "throws", reason: "world_surface_failed", behavior: () => { throw new Error("availability"); } }, + { name: "returns a thenable", reason: "world_surface_failed", behavior: () => ({ then: () => {} }) }, + { name: "returns malformed output", reason: "world_surface_failed", behavior: () => "true" }, + { + name: "caught nested act reentry", reason: "ingress_reentered", + behavior: (fixture, _dynamics, nestedResults) => { nestedResults.push(fixture.runtime!.act(context(fixture.red.token), envelope("nested-available"))); return true; }, + }, + { name: "uncaught nested act reentry", reason: "world_surface_failed", behavior: (fixture, _dynamics, nestedResults) => { nestedResults.push(fixture.runtime!.act(context(fixture.red.token), envelope("nested-available-uncaught"))); throw new Error("uncaught reentry"); } }, + { name: "queues a real action and is restored", reason: "world_state_unstable", behavior: (_fixture, dynamics) => { dynamics.queueAction(probe("availability-mutation")); return true; } }, + { name: "advances the real tick and is restored", reason: "world_state_unstable", behavior: (_fixture, dynamics) => { dynamics.step(); return true; } }, +]; + +test("availability is one callback, rollback-safe, and before lowering", () => { + for (const item of availabilityCases) { + let first = true; + let calls = 0; + const nestedResults: unknown[] = []; + let fixture!: Fixture; + fixture = runtimeFixtureWithHooks({ + available: () => { + calls += 1; + if (first) { first = false; return item.behavior(fixture, fixture.dynamics, nestedResults); } + return true; + }, + }); + assertDeniedAndRestored(fixture, item.reason, undefined, item.name); + assert.equal(calls, 1, item.name); + if (item.name.includes("nested act")) { + assert.equal(nestedResults.length, 1, item.name); + assert.deepEqual(nestedResults[0], denied("ingress_reentered")); + } + queueAfterDenial(fixture); + } +}); + +test("typed schema validation precedes lower", () => { + let lowerCalls = 0; + const fixture = runtimeFixtureWithHooks({ lower: () => { lowerCalls += 1; return { force: 1 }; } }); + const before = fixture.dynamics.snapshot(); + const beforeDecision = fixture.decisionRegistry.snapshot(); + assert.deepEqual(fixture.runtime!.act(context(fixture.red.token), runtimeActEnvelope("invalid-schema", { ...request, input: { force: 2 } })), denied("action_input_out_of_bounds", "force")); + assert.equal(lowerCalls, 0); + assert.deepEqual(fixture.dynamics.snapshot(), before); + assert.deepEqual(fixture.decisionRegistry.snapshot(), beforeDecision); + const journal = runtimeActionJournalSnapshot(fixture.runtime); + assert.ok(journal); + assert.deepEqual(journal.cells, []); + assert.deepEqual(journal.audits, [{ principal: "principal-red", result: "denied" }]); + queueAfterDenial(fixture); +}); + +const loweringCases: readonly { readonly name: string; readonly reason: WorldActIngressRejectionReason; readonly behavior: Behavior }[] = [ + { name: "throws", reason: "world_surface_failed", behavior: () => { throw new Error("lower"); } }, + { name: "returns a thenable", reason: "world_surface_failed", behavior: () => ({ then: () => {} }) }, + { name: "returns malformed output", reason: "world_surface_failed", behavior: () => [] }, + { name: "returns reserved output", reason: "internal_error", behavior: () => ({ receipt_id: "forbidden" }) }, + { name: "caught nested act reentry", reason: "ingress_reentered", behavior: (fixture, _dynamics, nestedResults) => { nestedResults.push(fixture.runtime!.act(context(fixture.red.token), envelope("nested-lower"))); return { force: 1 }; } }, + { name: "uncaught nested act reentry", reason: "world_surface_failed", behavior: (fixture, _dynamics, nestedResults) => { nestedResults.push(fixture.runtime!.act(context(fixture.red.token), envelope("nested-lower-uncaught"))); throw new Error("uncaught reentry"); } }, + { name: "queues a real action and is restored", reason: "world_state_unstable", behavior: (_fixture, dynamics) => { dynamics.queueAction(probe("lower-mutation")); return { force: 1 }; } }, + { name: "advances the real tick and is restored", reason: "world_state_unstable", behavior: (_fixture, dynamics) => { dynamics.step(); return { force: 1 }; } }, +]; + +test("checked lowering failures deny without consuming the original token", () => { + for (const item of loweringCases) { + let first = true; + let calls = 0; + const nestedResults: unknown[] = []; + let fixture!: Fixture; + fixture = runtimeFixtureWithHooks({ + lower: () => { + calls += 1; + if (first) { first = false; return item.behavior(fixture, fixture.dynamics, nestedResults); } + return { force: 1 }; + }, + }); + assertDeniedAndRestored(fixture, item.reason, undefined, item.name); + assert.equal(calls, 1, item.name); + if (item.name.includes("nested act")) { + assert.equal(nestedResults.length, 1, item.name); + assert.deepEqual(nestedResults[0], denied("ingress_reentered")); + } + queueAfterDenial(fixture); + } +}); + +test("a successful admission queues once and commits one real terminal cell", () => { + let lowerCalls = 0; + let projectionResultCalls = 0; + const fixture = runtimeFixtureWithHooks({ + lower: () => { lowerCalls += 1; return { force: 1 }; }, + projectResult: () => { projectionResultCalls += 1; return { outcome: true }; }, + step: (input) => { + const actions = (input as { readonly actions: readonly { readonly sequence: number }[] }).actions; + return { tick: 0, events: [], action_results: actions.map((queued) => ({ accepted: true, sequence: queued.sequence })) }; + }, + }); + const receipt = fixture.runtime!.act(context(fixture.red.token), envelope("successful-action")); + assert.equal(receipt.disposition, "queued"); + assert.equal(lowerCalls, 1); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 1); + assert.equal(fixture.dynamics.snapshot().action_ingress.length, 1); + const queued = runtimeActionJournalSnapshot(fixture.runtime)!; + assert.equal(queued.cells.length, 1); + assert.equal(queued.cells[0]!.state, "authorized"); + assert.equal(queued.cells[0]!.terminal, null); + assert.deepEqual(readWorldRuntimeClockAuthority(fixture.runtime)!.stepDynamics(), { tick: 0, action_results: 1, events: 0 }); + assert.equal(fixture.dynamics.nextTick, 1); + assert.equal(projectionResultCalls, 1); + const applied = runtimeActionJournalSnapshot(fixture.runtime)!; + assert.equal(applied.cells.length, 1); + assert.equal(applied.cells[0]!.state, "terminal"); + assert.equal(applied.cells[0]!.terminal?.disposition, "applied"); + assert.equal(applied.cells[0]!.terminal?.projection, "projected"); + assert.equal(applied.cells[0]!.terminal?.effect?.outcome, true); +}); diff --git a/src/world/worldActionIngressRealSession.test.ts b/src/world/worldActionIngressRealSession.test.ts new file mode 100644 index 0000000..a723ff6 --- /dev/null +++ b/src/world/worldActionIngressRealSession.test.ts @@ -0,0 +1,141 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import type { WorldActIngressRejectionReason } from "./actTypes.js"; +import { runtimeActionJournalSnapshot, runtimeActEnvelope, runtimeFixtureWithHooks } from "./runtime.test-helper.js"; + +const denied = (reason: WorldActIngressRejectionReason, fieldPath?: string) => Object.freeze({ + disposition: "rejected_at_ingress" as const, + code: "world_action_denied" as const, + reason, + ...(fieldPath === undefined ? {} : { field_path: fieldPath }), +}); +const context = (token: string) => ({ principal: "principal-red", decisionToken: token }); +const action = (affordance = "world://pitch/affordance/kick", target = "world://pitch/entity/ball", input: unknown = { force: 1 }) => ({ affordance, target, input }); + +const assertDenied = ( + fixture: ReturnType, + bytes: Uint8Array, + callbacks: () => number, + reason: WorldActIngressRejectionReason, +): void => { + const beforeDynamics = fixture.dynamics.snapshot(); + const beforeDecision = fixture.decisionRegistry.snapshot(); + assert.deepEqual(fixture.runtime!.act(context(fixture.red.token), bytes), denied(reason)); + assert.equal(fixture.dynamics.nextTick, beforeDynamics.next_tick); + assert.deepEqual(fixture.dynamics.snapshot(), beforeDynamics); + assert.deepEqual(fixture.decisionRegistry.snapshot(), beforeDecision); + assert.equal(callbacks(), 0); + const journal = runtimeActionJournalSnapshot(fixture.runtime); + assert.ok(journal); + assert.deepEqual(journal.cells, []); + assert.deepEqual(journal.audits, [{ principal: "principal-red", result: "denied" }]); +}; + +const queueLater = (fixture: ReturnType): void => { + const receipt = fixture.runtime!.act(context(fixture.red.token), runtimeActEnvelope("after-denial", action())); + assert.equal(receipt.disposition, "queued"); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 1); + assert.equal(runtimeActionJournalSnapshot(fixture.runtime)!.cells[0]!.state, "authorized"); +}; + +test("hostile and malformed bytes are denied at real-session ingress", () => { + class ByteSubclass extends Uint8Array {} + const makeCases = (): readonly { readonly name: string; readonly bytes: unknown; readonly reason: WorldActIngressRejectionReason }[] => { + const valid = runtimeActEnvelope("malformed", action()); + const mutated = Uint8Array.from(valid); + mutated[0] ^= 1; + return [ + { name: "empty", bytes: new Uint8Array(), reason: "request_malformed" }, + { name: "invalid json", bytes: new TextEncoder().encode("not-json\n"), reason: "request_malformed" }, + { name: "changed canonical bytes", bytes: mutated, reason: "request_malformed" }, + { name: "proxy", bytes: new Proxy(valid, {}), reason: "request_malformed" }, + { name: "subclass", bytes: new ByteSubclass(valid), reason: "request_malformed" }, + { name: "object compatibility route", bytes: { 0: valid[0], length: valid.length }, reason: "request_malformed" }, + ]; + }; + for (const requestCase of makeCases()) { + let callbacks = 0; + const fixture = runtimeFixtureWithHooks({ + observe: () => { callbacks += 1; return { channels: [] }; }, + project: () => { callbacks += 1; return { channels: [] }; }, + available: () => { callbacks += 1; return true; }, + lower: () => { callbacks += 1; return { force: 1 }; }, + }); + assert.doesNotThrow(() => assertDenied(fixture, requestCase.bytes as never, () => callbacks, requestCase.reason), requestCase.name); + queueLater(fixture); + } +}); + +const admissionCases: readonly { readonly name: string; readonly request: unknown; readonly reason: WorldActIngressRejectionReason; readonly identity?: Parameters[2] }[] = [ + { name: "malformed affordance", request: action("not-an-affordance"), reason: "affordance_not_granted" }, + { name: "foreign affordance", request: action("world://other/affordance/kick"), reason: "affordance_not_granted" }, + { name: "wrong-kind resource", request: action("world://pitch/entity/ball"), reason: "affordance_not_granted" }, + { name: "ungranted affordance", identity: { runId: "run-1", worldInstanceId: "instance-1", redAffordances: ["world://pitch/affordance/kick"] }, request: action("world://pitch/affordance/wait", "world://pitch/entity/red"), reason: "affordance_not_granted" }, + { name: "holder target mismatch", request: action("world://pitch/affordance/wait", "world://pitch/entity/ball"), reason: "target_not_granted" }, + { name: "fixed target mismatch", request: action("world://pitch/affordance/kick", "world://pitch/entity/red"), reason: "target_not_granted" }, + { name: "declared but ungranted target", request: action("world://pitch/affordance/kick", "world://pitch/entity/blue"), reason: "target_not_granted" }, +]; + +test("manifest and selector admission precede every checked callback", () => { + for (const admissionCase of admissionCases) { + let callbacks = 0; + const fixture = runtimeFixtureWithHooks({ + observe: () => { callbacks += 1; return { channels: [] }; }, + project: () => { callbacks += 1; return { channels: [] }; }, + available: () => { callbacks += 1; return true; }, + lower: () => { callbacks += 1; return { force: 1 }; }, + }, true, admissionCase.identity); + assertDenied(fixture, runtimeActEnvelope(`admission-${admissionCase.name}`, admissionCase.request as never), () => callbacks, admissionCase.reason); + queueLater(fixture); + } +}); + +test("unknown principal receives no action-journal audit", () => { + const fixture = runtimeFixtureWithHooks({ observe: () => { throw new Error("must not run"); } }); + const beforeDynamics = fixture.dynamics.snapshot(); + const beforeDecision = fixture.decisionRegistry.snapshot(); + assert.deepEqual(fixture.runtime!.act({ principal: "principal-unknown", decisionToken: fixture.red.token }, runtimeActEnvelope("unknown-principal", action())), denied("principal_unknown")); + assert.deepEqual(fixture.dynamics.snapshot(), beforeDynamics); + assert.deepEqual(fixture.decisionRegistry.snapshot(), beforeDecision); + const journal = runtimeActionJournalSnapshot(fixture.runtime); + assert.ok(journal); + assert.deepEqual(journal.audits, []); + assert.deepEqual(journal.cells, []); +}); + +test("real runtime refusals distinguish missing, bounded, and grant causes", () => { + const fixture = runtimeFixtureWithHooks({}); + const missing = fixture.runtime!.act(context(fixture.red.token), runtimeActEnvelope("missing-input", action(undefined, undefined, {}))); + const bounded = fixture.runtime!.act(context(fixture.red.token), runtimeActEnvelope("bounded-input", action(undefined, undefined, { force: 2 }))); + const ungranted = fixture.runtime!.act(context(fixture.red.token), runtimeActEnvelope("ungranted-input", action("world://pitch/affordance/absent"))); + + assert.deepEqual(missing, denied("action_input_missing_field", "force")); + assert.deepEqual(bounded, denied("action_input_out_of_bounds", "force")); + assert.deepEqual(ungranted, denied("affordance_not_granted")); + const reasons = [missing, bounded, ungranted].map((receipt) => + receipt.disposition === "rejected_at_ingress" ? receipt.reason : "queued"); + assert.equal(new Set(reasons).size, 3); +}); + +test("hostile input keys and values never enter rejection receipts", () => { + const fixture = runtimeFixtureWithHooks({}); + const hostileKey = ""; + const hostileValue = ""; + const unknown = fixture.runtime!.act(context(fixture.red.token), runtimeActEnvelope("hostile-unknown", action( + undefined, + undefined, + { force: 1, [hostileKey]: hostileValue }, + ))); + assert.deepEqual(unknown, denied("action_input_out_of_bounds")); + assert.equal(Object.hasOwn(unknown, "field_path"), false); + assert.equal(JSON.stringify(unknown).includes(hostileKey), false); + assert.equal(JSON.stringify(unknown).includes(hostileValue), false); + + const declared = fixture.runtime!.act(context(fixture.red.token), runtimeActEnvelope("hostile-declared", action( + undefined, + undefined, + { force: hostileValue }, + ))); + assert.deepEqual(declared, denied("action_input_wrong_type", "force")); + assert.equal(JSON.stringify(declared).includes(hostileValue), false); +}); diff --git a/src/world/worldActionMechanicsRealSession.test.ts b/src/world/worldActionMechanicsRealSession.test.ts new file mode 100644 index 0000000..1716c90 --- /dev/null +++ b/src/world/worldActionMechanicsRealSession.test.ts @@ -0,0 +1,240 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { readCheckedDynamicsSession } from "../dynamics/session.js"; +import { readParsedWorldSurfaceRegistry } from "../world-surface/definition.js"; +import { readWorldActionResultLedger } from "./actionResultLedger.js"; +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import { runtimeActionJournalSnapshot, runtimeActionJournalStatus, runtimeActionResultLedger, runtimeActEnvelope, runtimeFixtureWithHooks, runtimeRequestLedgerSnapshot } from "./runtime.test-helper.js"; + +const redAction = Object.freeze({ + affordance: "world://pitch/affordance/kick", + target: "world://pitch/entity/ball", + input: { force: 1 }, +}); +const blueAction = Object.freeze({ + affordance: "world://pitch/affordance/wait", + target: "world://pitch/entity/blue", + input: { force: 1 }, +}); +const commands = (input: unknown): readonly { readonly sequence: number }[] => + (input as { readonly actions: readonly { readonly sequence: number }[] }).actions; +const accepted = (input: unknown) => commands(input).map(({ sequence }) => ({ accepted: true, sequence })); +const event = (causes: number[]) => ({ + cause_action_sequences: causes, kind: "impact", payload: { strength: 1 }, source: "system:test", target: "object:ball", +}); +const queueRed = (fixture: ReturnType) => { + const receipt = fixture.runtime!.act({ principal: "principal-red", decisionToken: fixture.red.token }, runtimeActEnvelope("red-action", redAction)); + assert.equal(receipt.disposition, "queued"); + return receipt; +}; + +// Foreign, duplicate, and identity-corrupt post-step joins remain unreachable through this +// checked session; actionResults.test.ts retains those direct resolver proofs. The matrix below +// exercises the reachable checked-session result-mode boundary before post-step joining. +test("a real checked provider commits accepted and declared rejected mechanics durably", () => { + let projections = 0; + const appliedFixture = runtimeFixtureWithHooks({ + step: (input) => ({ tick: 0, events: [], action_results: accepted(input) }), + projectResult: () => { projections += 1; return { outcome: true }; }, + }); + assert.ok(readCheckedDynamicsSession(appliedFixture.dynamics)); + assert.ok(readParsedWorldSurfaceRegistry(appliedFixture.surfaceRegistry)); + const appliedReceipt = queueRed(appliedFixture); + const appliedClock = readWorldRuntimeClockAuthority(appliedFixture.runtime!); + assert.ok(appliedClock); + assert.deepEqual(appliedClock.stepDynamics(), { tick: 0, action_results: 1, events: 0 }); + assert.equal(appliedFixture.dynamics.nextTick, 1); + assert.equal(appliedFixture.dynamics.snapshot().pending_actions.length, 0); + assert.equal(projections, 1); + const applied = runtimeActionJournalSnapshot(appliedFixture.runtime!)!; + assert.deepEqual(applied.cells.map((cell) => [cell.receipt.receipt_id, cell.terminal?.disposition, cell.terminal?.projection]), [ + [appliedReceipt.receipt_id, "applied", "projected"], + ]); + + let rejectedProjections = 0; + const rejectedFixture = runtimeFixtureWithHooks({ + step: (input) => ({ tick: 0, events: [], action_results: commands(input).map(({ sequence }) => ({ accepted: false, code: "blocked", sequence })) }), + projectResult: () => { rejectedProjections += 1; return { unexpected: true }; }, + }); + const rejectedReceipt = queueRed(rejectedFixture); + readWorldRuntimeClockAuthority(rejectedFixture.runtime!)!.stepDynamics(); + assert.equal(rejectedFixture.dynamics.nextTick, 1); + assert.equal(rejectedFixture.dynamics.snapshot().pending_actions.length, 0); + assert.equal(rejectedProjections, 0); + assert.deepEqual(runtimeActionJournalSnapshot(rejectedFixture.runtime!)!.cells[0]!.terminal, { + disposition: "rejected_at_mechanics", receipt_id: rejectedReceipt.receipt_id, + decision_id: rejectedReceipt.decision_id, sequence: 1, apply_tick: 0, + projection: "not_configured", public_code: "blocked", + }); +}); + +const checkedRejectionMustClose = (name: string, result: Readonly>) => { + const fixture = runtimeFixtureWithHooks({ step: (input) => ({ + tick: 0, events: [], action_results: commands(input).map(({ sequence }) => ({ ...result, sequence })), + }) }); + const receipt = queueRed(fixture); + const clock = readWorldRuntimeClockAuthority(fixture.runtime!)!; + assert.throws(() => clock.stepDynamics(), name); + assert.equal(fixture.dynamics.nextTick, 0); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 1); + const pending = runtimeActionJournalSnapshot(fixture.runtime!)!; + assert.equal(pending.closed, true); + assert.deepEqual(pending.cells.map((cell) => [cell.receipt.receipt_id, cell.state, cell.terminal]), [[receipt.receipt_id, "authorized", null]]); + assert.equal(runtimeActionJournalStatus(fixture.runtime!)!.closed, true); + assert.equal(runtimeRequestLedgerSnapshot(fixture.runtime!)!.closed, true); + assert.deepEqual(runtimeActionResultLedger(fixture.runtime!)!.read("principal-red", { version: "simfile.world-action-result-page-request.v1" }).results, []); + assert.throws(() => clock.stepDynamics(), /closed/u); +}; + +test("real checked provider retains undeclared rejection truth while exposing the fixed public code", () => { + const fixture = runtimeFixtureWithHooks({ + step: (input) => ({ + tick: 0, + events: [], + action_results: commands(input).map(({ sequence }) => ({ accepted: false, code: "private_code", sequence })), + }), + }); + const receipt = queueRed(fixture); + const clock = readWorldRuntimeClockAuthority(fixture.runtime!)!; + assert.deepEqual(clock.stepDynamics(), { tick: 0, action_results: 1, events: 0 }); + assert.equal(fixture.dynamics.nextTick, 1); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + assert.deepEqual(runtimeActionJournalSnapshot(fixture.runtime!)!.cells.map((cell) => cell.terminal), [{ + disposition: "rejected_at_mechanics", + receipt_id: receipt.receipt_id, + decision_id: receipt.decision_id, + sequence: 1, + apply_tick: 0, + projection: "not_configured", + public_code: "world_action_rejected", + }]); +}); + +test("checked session closes on provider rejections missing a code without fabricating a terminal", () => { + checkedRejectionMustClose("missing", { accepted: false }); +}); + +const bindResultLedger = (fixture: ReturnType) => { + const ledger = runtimeActionResultLedger(fixture.runtime); + assert.ok(ledger); + return ledger; +}; + +test("real result-mode checked validation rejects invalid provider and causality outputs atomically", () => { + const cases = [ + ["foreign", false, (actions: readonly { readonly sequence: number }[]) => [{ accepted: true, sequence: actions[0]!.sequence + 1 }]], + ["duplicate", true, (actions: readonly { readonly sequence: number }[]) => actions.map(() => ({ accepted: true, sequence: 1 }))], + ["missing", false, () => []], + ["identity-corrupt", false, (actions: readonly { readonly sequence: number }[]) => [{ accepted: true, sequence: actions[0]!.sequence, identity: { actor: "foreign" } }]], + ["missing rejection code", false, (actions: readonly { readonly sequence: number }[]) => [{ accepted: false, sequence: actions[0]!.sequence }]], + ["rejected", false, (actions: readonly { readonly sequence: number }[]) => [{ accepted: false, code: "blocked", sequence: actions[0]!.sequence }]], + ["unknown", false, (actions: readonly { readonly sequence: number }[]) => [{ accepted: true, sequence: actions[0]!.sequence }]], + ] as const; + for (const [name, twoActions, output] of cases) { + const fixture = runtimeFixtureWithHooks({ step: (input) => { + const actions = commands(input); + const actionResults = output(actions); + return { tick: 0, action_results: actionResults, events: name === "rejected" ? [event([actions[0]!.sequence])] : name === "unknown" ? [event([99])] : [] }; + } }); + const ledger = bindResultLedger(fixture); + queueRed(fixture); + if (twoActions) { + const receipt = fixture.runtime!.act({ principal: "principal-blue", decisionToken: fixture.blue.token }, runtimeActEnvelope(`${name}-blue`, blueAction)); + assert.equal(receipt.disposition, "queued"); + } + const authority = readWorldActionResultLedger(ledger)!; + const clock = readWorldRuntimeClockAuthority(fixture.runtime!)!; + assert.throws(() => clock.stepDynamics()); + assert.equal(fixture.dynamics.nextTick, 0); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, twoActions ? 2 : 1); + assert.equal(authority.hasLiveReservation(), false); + assert.deepEqual(ledger.read("principal-red", { version: "simfile.world-action-result-page-request.v1" }).results, []); + assert.deepEqual(ledger.read("principal-blue", { version: "simfile.world-action-result-page-request.v1" }).results, []); + const journal = runtimeActionJournalSnapshot(fixture.runtime!)!; + assert.equal(journal.closed, true); + assert.deepEqual(journal.cells.map((cell) => [cell.state, cell.terminal]), journal.cells.map(() => ["authorized", null])); + assert.equal(runtimeActionJournalStatus(fixture.runtime!)!.closed, true); + assert.equal(runtimeRequestLedgerSnapshot(fixture.runtime!)!.closed, true); + assert.equal(fixture.dynamics.nextTick, 0); + assert.throws(() => clock.stepDynamics(), /closed/u); + assert.equal(authority.hasLiveReservation(), false); + } +}); + +test("provider throw rolls back a real step and later resolves its retained action exactly once", () => { + let throws = true; + let steps = 0; + let projections = 0; + const fixture = runtimeFixtureWithHooks({ + step: (input) => { + steps += 1; + if (throws) { throws = false; throw new Error("provider step failure"); } + return { tick: 0, events: [event([commands(input)[0]!.sequence])], action_results: accepted(input) }; + }, + projectResult: () => { projections += 1; return { recovered: true }; }, + }); + const ledger = bindResultLedger(fixture); + const receipt = queueRed(fixture); + const clock = readWorldRuntimeClockAuthority(fixture.runtime!)!; + assert.throws(() => clock.stepDynamics(), /checked step failed/u); + assert.equal(fixture.dynamics.nextTick, 0); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 1); + assert.deepEqual(runtimeActionJournalSnapshot(fixture.runtime!)!.cells.map((cell) => [cell.receipt.receipt_id, cell.state, cell.terminal]), [[receipt.receipt_id, "authorized", null]]); + assert.equal(readWorldActionResultLedger(ledger)!.hasLiveReservation(), false); + assert.equal(clock.stepDynamics().tick, 0); + assert.equal(steps, 2); + assert.equal(projections, 1); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + const page = ledger.read("principal-red", { version: "simfile.world-action-result-page-request.v1" }); + assert.deepEqual(page.results.map((result) => result.result_id), ["world-result-1"]); + assert.deepEqual((page.results[0]! as Extract).caused_effect_ids, ["world-effect-1"]); + assert.deepEqual(runtimeActionJournalSnapshot(fixture.runtime!)!.cells.map((cell) => cell.terminal?.disposition), ["applied"]); +}); + +test("real provider event causality accepts current, prior, and uncaused events and rejects rejected or unknown causes", () => { + const current = runtimeFixtureWithHooks({ + step: (input) => ({ tick: 0, action_results: accepted(input), events: [event([commands(input)[0]!.sequence])] }), + }); + queueRed(current); + assert.equal(readWorldRuntimeClockAuthority(current.runtime!)!.stepDynamics().events, 1); + + let priorStep = 0; + const prior = runtimeFixtureWithHooks({ step: (input) => { + priorStep += 1; + return { tick: priorStep - 1, action_results: accepted(input), events: [event(priorStep === 1 ? [commands(input)[0]!.sequence] : [1])] }; + } }); + queueRed(prior); + const priorClock = readWorldRuntimeClockAuthority(prior.runtime!)!; + priorClock.stepDynamics(); + assert.equal(prior.runtime!.act({ principal: "principal-blue", decisionToken: prior.blue.token }, runtimeActEnvelope("blue-action", blueAction)).disposition, "queued"); + assert.equal(priorClock.stepDynamics().events, 1); + + const uncaused = runtimeFixtureWithHooks({ step: (input) => ({ tick: 0, action_results: accepted(input), events: [event([])] }) }); + queueRed(uncaused); + assert.equal(readWorldRuntimeClockAuthority(uncaused.runtime!)!.stepDynamics().events, 1); + + for (const [name, actionResults, causes] of [ + ["rejected", [{ accepted: false, code: "blocked", sequence: 1 }], [1]], + ["unknown", [{ accepted: true, sequence: 1 }], [99]], + ] as const) { + const fixture = runtimeFixtureWithHooks({ step: () => ({ tick: 0, action_results: actionResults, events: [event([...causes])] }) }); + queueRed(fixture); + assert.throws(() => readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(), name); + assert.equal(fixture.dynamics.nextTick, 0); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 1); + assert.equal(runtimeActionJournalSnapshot(fixture.runtime!)!.cells[0]!.terminal, null); + } +}); + +test("the result-mode integration uses the same checked session for causal publication", () => { + const fixture = runtimeFixtureWithHooks({ step: (input) => ({ tick: 0, action_results: accepted(input), events: [event([commands(input)[0]!.sequence])] }) }); + const ledger = runtimeActionResultLedger(fixture.runtime); + assert.ok(ledger); + queueRed(fixture); + assert.equal(readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics().events, 1); + const page = ledger.read("principal-red", { version: "simfile.world-action-result-page-request.v1" }); + assert.equal(page.results.length, 1); assert.equal(page.results[0]!.status, "applied"); + assert.deepEqual((page.results[0]! as Extract).caused_effect_ids, ["world-effect-1"]); + assert.equal(fixture.dynamics.nextTick, 1); +}); diff --git a/src/world/worldActionProjectionRealSession.test.ts b/src/world/worldActionProjectionRealSession.test.ts new file mode 100644 index 0000000..9eaff4f --- /dev/null +++ b/src/world/worldActionProjectionRealSession.test.ts @@ -0,0 +1,190 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import type { DynamicsSession } from "../dynamics/session.js"; +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; +import type { WorldActIngressRejectionReason } from "./actTypes.js"; +import { runtimeActionJournalSnapshot, runtimeActEnvelope, runtimeFixtureWithHooks, type RuntimeFixtureHooks } from "./runtime.test-helper.js"; + +const denied = (reason: WorldActIngressRejectionReason) => Object.freeze({ + disposition: "rejected_at_ingress" as const, + code: "world_action_denied" as const, + reason, +}); + +const redAction = Object.freeze({ + affordance: "world://pitch/affordance/kick", + target: "world://pitch/entity/ball", + input: { force: 1 }, +}); +const blueAction = Object.freeze({ + affordance: "world://pitch/affordance/wait", + target: "world://pitch/entity/blue", + input: { force: 1 }, +}); +const commands = (input: unknown): readonly { readonly sequence: number }[] => + (input as { readonly actions: readonly { readonly sequence: number }[] }).actions; +const realAcceptedStep = (input: unknown) => ({ + tick: (input as { readonly tick: number }).tick, + events: [], + action_results: commands(input).map(({ sequence }) => ({ accepted: true, sequence })), +}); +const queueRed = (fixture: ReturnType) => { + const receipt = fixture.runtime!.act({ principal: "principal-red", decisionToken: fixture.red.token }, runtimeActEnvelope("red-action", redAction)); + assert.equal(receipt.disposition, "queued"); + return receipt; +}; +const queuedMutation = (dynamics: DynamicsSession) => dynamics.queueAction({ + act_id: "projection-mutation", action: "wait", actor: "object:blue", at_tick: dynamics.nextTick, + input: {}, origin: "agentic", principal_id: "principal-blue", target: "object:blue", +}); + +test("real checked projections honestly record absent, throw, thenable, and malformed callbacks", () => { + const cases: readonly [string, RuntimeFixtureHooks["projectResult"], "not_configured" | "failed", number][] = [ + ["absent", null, "not_configured", 0], + ["throw", () => { throw new Error("projection throw"); }, "failed", 1], + ["thenable", () => ({ then: () => {} }), "failed", 1], + ["malformed", () => [] as unknown as Record, "failed", 1], + ]; + for (const [name, callback, expected, callbacks] of cases) { + let calls = 0; + const fixture = runtimeFixtureWithHooks({ + step: realAcceptedStep, + projectResult: callback === null ? null : (input, dynamics) => { calls += 1; return callback!(input, dynamics); }, + }); + const receipt = queueRed(fixture); + assert.equal(readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics().tick, 0, name); + assert.equal(calls, callbacks, name); + assert.equal(fixture.dynamics.nextTick, 1, name); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0, name); + const terminal = runtimeActionJournalSnapshot(fixture.runtime!)!.cells[0]!.terminal!; + assert.equal(terminal.receipt_id, receipt.receipt_id, name); + assert.equal(terminal.disposition, "applied", name); + assert.equal(terminal.projection, expected, name); + assert.equal(runtimeActionJournalSnapshot(fixture.runtime!)!.closed, false, name); + } +}); + +test("reserved real projection output records the applied fact as failed without closing mechanics", () => { + let calls = 0; + const fixture = runtimeFixtureWithHooks({ + step: realAcceptedStep, + projectResult: () => { calls += 1; return { receipt: "invented" }; }, + }); + const receipt = queueRed(fixture); + readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + assert.equal(calls, 1); + assert.equal(fixture.dynamics.nextTick, 1); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + const snapshot = runtimeActionJournalSnapshot(fixture.runtime!)!; + assert.equal(snapshot.closed, false); + assert.deepEqual(snapshot.cells.map((cell) => [cell.receipt.receipt_id, cell.terminal?.disposition, cell.terminal?.projection]), [ + [receipt.receipt_id, "applied", "failed"], + ]); +}); + +test("caught and uncaught actual runtime reentry fail only the already-applied projection", () => { + for (const uncaught of [false, true]) { + let calls = 0; + let nested: unknown; + let fixture: ReturnType; + fixture = runtimeFixtureWithHooks({ + step: realAcceptedStep, + projectResult: () => { + calls += 1; + nested = fixture.runtime!.act({ principal: "principal-blue", decisionToken: fixture.blue.token }, runtimeActEnvelope("blue-reentry", blueAction)); + if (uncaught) throw new Error("projection reentry escape"); + return { callback: "returned" }; + }, + }); + queueRed(fixture); + readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + assert.equal(calls, 1); + assert.deepEqual(nested, denied("ingress_reentered")); + assert.equal(runtimeActionJournalSnapshot(fixture.runtime!)!.cells[0]!.terminal?.projection, "failed"); + assert.equal(fixture.dynamics.nextTick, 1); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + assert.equal(fixture.runtime!.act({ principal: "principal-blue", decisionToken: fixture.blue.token }, runtimeActEnvelope("blue-after-reentry", blueAction)).disposition, "queued"); + } +}); + +test("real dynamics queue and tick mutations are restored after projection without erasing mechanics", () => { + for (const mutation of [ + (dynamics: DynamicsSession) => queuedMutation(dynamics), + (dynamics: DynamicsSession) => dynamics.step(), + ]) { + let calls = 0; + const fixture = runtimeFixtureWithHooks({ + step: realAcceptedStep, + projectResult: (_input, dynamics) => { calls += 1; mutation(dynamics); return { mutation: true }; }, + }); + const receipt = queueRed(fixture); + readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(); + assert.equal(calls, 1); + assert.equal(fixture.dynamics.nextTick, 1); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + const terminal = runtimeActionJournalSnapshot(fixture.runtime!)!.cells[0]!.terminal!; + assert.equal(terminal.receipt_id, receipt.receipt_id); + assert.equal(terminal.disposition, "applied"); + assert.equal(terminal.projection, "failed"); + } +}); + +test("a real provider restore failure closes future clock and act operations after the applied fact", () => { + let callbacks = 0; + let failRestore = true; + const fixture = runtimeFixtureWithHooks({ + step: realAcceptedStep, + projectResult: () => { callbacks += 1; throw new Error("projection failure"); }, + failRestore: () => { + if (!failRestore) return false; + failRestore = false; + return true; + }, + }); + const receipt = queueRed(fixture); + const clock = readWorldRuntimeClockAuthority(fixture.runtime!)!; + assert.throws(() => clock.stepDynamics(), /restore failed/u); + assert.equal(callbacks, 1); + assert.equal(fixture.restoreCalls(), 2); + assert.equal(fixture.dynamics.nextTick, 1); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 0); + const snapshot = runtimeActionJournalSnapshot(fixture.runtime!)!; + assert.equal(snapshot.closed, true); + assert.deepEqual(snapshot.cells.map((cell) => [cell.receipt.receipt_id, cell.terminal?.disposition, cell.terminal?.projection]), [ + [receipt.receipt_id, "applied", "not_configured"], + ]); + assert.deepEqual(fixture.runtime!.act({ principal: "principal-blue", decisionToken: fixture.blue.token }, runtimeActEnvelope("blue-after-close", blueAction)), denied("ingress_closed")); + assert.throws(() => clock.stepDynamics(), /mechanics closed/u); +}); + +test("multi-action real projection is ordered and fatal restore failure prevents later callbacks or partial projection success", () => { + let callbacks = 0; + let beforeFirstProjection: unknown; + let failRestore = true; + let fixture: ReturnType; + fixture = runtimeFixtureWithHooks({ + step: realAcceptedStep, + projectResult: () => { + callbacks += 1; + beforeFirstProjection ??= runtimeActionJournalSnapshot(fixture.runtime!)!.cells.map((cell) => [cell.sequence, cell.terminal?.disposition, cell.terminal?.projection]); + throw new Error("first projection fails"); + }, + failRestore: () => { + if (!failRestore) return false; + failRestore = false; + return true; + }, + }); + const red = queueRed(fixture); + const blue = fixture.runtime!.act({ principal: "principal-blue", decisionToken: fixture.blue.token }, runtimeActEnvelope("blue-action", blueAction)); + assert.equal(blue.disposition, "queued"); + assert.throws(() => readWorldRuntimeClockAuthority(fixture.runtime!)!.stepDynamics(), /restore failed/u); + assert.equal(callbacks, 1); + assert.deepEqual(beforeFirstProjection, [[1, "applied", "not_configured"], [2, "applied", "not_configured"]]); + const snapshot = runtimeActionJournalSnapshot(fixture.runtime!)!; + assert.equal(snapshot.closed, true); + assert.deepEqual(snapshot.cells.map((cell) => [cell.receipt.receipt_id, cell.record.mechanics_action, cell.terminal?.projection]), [ + [red.receipt_id, "kick", "not_configured"], [blue.receipt_id, "wait", "not_configured"], + ]); +}); diff --git a/src/world/worldActionRealSession.test.ts b/src/world/worldActionRealSession.test.ts new file mode 100644 index 0000000..72f2d3d --- /dev/null +++ b/src/world/worldActionRealSession.test.ts @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { readCheckedDynamicsSession } from "../dynamics/session.js"; +import { readParsedWorldSurfaceRegistry } from "../world-surface/definition.js"; +import { runtimeActionJournalSnapshot, runtimeActEnvelope, runtimeFixtureWithHooks } from "./runtime.test-helper.js"; +import { readWorldRuntimeActionJournalInspection } from "./actionJournalInspection.js"; +import { readWorldRuntimeClockAuthority } from "./clockAuthority.js"; + +const action = { + affordance: "world://pitch/affordance/kick", + target: "world://pitch/entity/ball", + input: { force: 1 }, +}; + +test("real runtime actions are inspectable across queue and accepted step", () => { + const fixture = runtimeFixtureWithHooks({ + step: (input) => { + const queued = (input as { actions: readonly { sequence: number }[] }).actions[0]!; + return { tick: 0, events: [], action_results: [{ accepted: true, sequence: queued.sequence }] }; + }, + }); + const runtime = fixture.runtime!; + + assert.ok(readCheckedDynamicsSession(fixture.dynamics)); + assert.ok(readParsedWorldSurfaceRegistry(fixture.surfaceRegistry)); + const clock = readWorldRuntimeClockAuthority(runtime); + assert.ok(clock); + const receipt = runtime.act( + { principal: "principal-red", decisionToken: fixture.red.token }, runtimeActEnvelope("real-action", action), + ); + if (receipt.disposition !== "queued") throw new Error("expected queued receipt"); + assert.equal(fixture.dynamics.snapshot().pending_actions.length, 1); + + const queued = runtimeActionJournalSnapshot(runtime)!; + assert.equal(queued.cells.length, 1); + assert.equal(queued.cells[0]!.state, "authorized"); + assert.equal(queued.cells[0]!.terminal, null); + assert.equal(queued.cells[0]!.receipt.receipt_id, receipt.receipt_id); + + const step = clock.stepDynamics(); + assert.deepEqual(step, { tick: 0, action_results: 1, events: 0 }); + assert.equal(fixture.dynamics.nextTick, 1); + const applied = runtimeActionJournalSnapshot(runtime)!; + assert.equal(applied.cells[0]!.receipt.receipt_id, receipt.receipt_id); + const terminal = applied.cells[0]!.terminal!; + assert.equal(terminal.disposition, "applied"); + assert.equal(terminal.receipt_id, receipt.receipt_id); + assert.equal(terminal.sequence, 1); + assert.equal(terminal.apply_tick, 0); + assert.equal(terminal.projection, "projected"); + assert.equal(terminal.effect?.outcome, true); +}); + +test("inspection is exact-runtime, immutable, and read-only", () => { + const fixture = runtimeFixtureWithHooks({ + step: (input) => { + const queued = (input as { actions: readonly { sequence: number }[] }).actions[0]!; + return { tick: 0, events: [], action_results: [{ accepted: true, sequence: queued.sequence }] }; + }, + }); + const runtime = fixture.runtime!; + const inspection = readWorldRuntimeActionJournalInspection(runtime); + assert.ok(inspection); + assert.equal(readWorldRuntimeActionJournalInspection({ ...runtime }), undefined); + assert.equal(readWorldRuntimeActionJournalInspection({}), undefined); + assert.equal(readWorldRuntimeActionJournalInspection(null), undefined); + assert.deepEqual(Object.keys(inspection), ["snapshot", "status"]); + assert.equal("reserve" in inspection, false); + assert.equal("audit" in inspection, false); + assert.deepEqual(inspection.status(), { closed: false, audit_count: 0, cell_count: 0 }); + + const receipt = runtime.act( + { principal: "principal-red", decisionToken: fixture.red.token }, runtimeActEnvelope("inspection-action", action), + ); + if (receipt.disposition !== "queued") throw new Error("expected queued receipt"); + const snapshot = inspection.snapshot(); + assert.ok(Object.isFrozen(snapshot)); + assert.ok(Object.isFrozen(snapshot.cells)); + assert.ok(Object.isFrozen(snapshot.cells[0])); + assert.ok(Object.isFrozen(snapshot.cells[0]!.receipt)); + assert.equal(snapshot.cells[0]!.receipt.receipt_id, receipt.receipt_id); + assert.throws(() => (snapshot.cells as unknown as unknown[]).pop(), TypeError); + const sourceIsolated = inspection.snapshot(); + assert.deepEqual(sourceIsolated, snapshot); +}); diff --git a/tools/AGENTS.md b/tools/AGENTS.md new file mode 100644 index 0000000..c823544 --- /dev/null +++ b/tools/AGENTS.md @@ -0,0 +1,10 @@ +# Simfile Repository Tools + +This folder contains repository-maintenance tools. Resolve repository paths +relative to each tool's own module URL, keep checks read-only, and keep refresh +operations deterministic and fail-closed. + +- `refreshVendorStele.mjs` refreshes the integrity-pinned source tarball while + preserving Simfile's release-safe exact dependency coordinate and bundle. +- `verify-package-closure.mjs` packs and offline-installs Simfile, checks the + bundled Stele closure and runtime imports, and starts the installed CLI. diff --git a/tools/CLAUDE.md b/tools/CLAUDE.md new file mode 120000 index 0000000..47dc3e3 --- /dev/null +++ b/tools/CLAUDE.md @@ -0,0 +1 @@ +AGENTS.md \ No newline at end of file diff --git a/tools/verify-package-closure.mjs b/tools/verify-package-closure.mjs new file mode 100644 index 0000000..4c6f6a6 --- /dev/null +++ b/tools/verify-package-closure.mjs @@ -0,0 +1,225 @@ +#!/usr/bin/env node + +import { spawn } from "node:child_process"; +import { createHash } from "node:crypto"; +import { lstat, mkdir, mkdtemp, readFile, realpath, rm, writeFile } from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); +const STELE = "@noopolis/stele"; +const STELE_VERSION = "0.0.2"; + +const fail = (message) => { + throw new Error(message); +}; + +const readJson = async (filePath) => JSON.parse(await readFile(filePath, "utf8")); + +const run = (command, args, cwd, env = process.env) => new Promise((resolve, reject) => { + const child = spawn(command, args, { cwd, env, stdio: ["ignore", "pipe", "pipe"] }); + let stdout = ""; + let stderr = ""; + child.stdout.setEncoding("utf8"); + child.stderr.setEncoding("utf8"); + child.stdout.on("data", (chunk) => { stdout += chunk; }); + child.stderr.on("data", (chunk) => { stderr += chunk; }); + child.on("error", reject); + child.on("close", (code) => { + if (code !== 0) { + reject(new Error(`${command} ${args.join(" ")} failed (${code})\n${stderr}`)); + return; + } + resolve({ stderr, stdout }); + }); +}); + +const parseSinglePack = (stdout) => { + let parsed; + for (let index = stdout.lastIndexOf("["); index >= 0; index = stdout.lastIndexOf("[", index - 1)) { + try { + const candidate = JSON.parse(stdout.slice(index)); + if (Array.isArray(candidate)) { + parsed = candidate; + break; + } + } catch { + // Lifecycle scripts may write before npm's final JSON array. + } + } + if (!Array.isArray(parsed) || parsed.length !== 1) { + fail("npm pack must report exactly one tarball"); + } + const [result] = parsed; + if (!result || typeof result.filename !== "string" || !Array.isArray(result.files)) { + fail("npm pack returned an invalid manifest"); + } + return result; +}; + +const assertRegistrySource = async (manifest, lock) => { + if (manifest.dependencies?.[STELE] !== STELE_VERSION) { + fail(`${STELE} must use the published ${STELE_VERSION} release coordinate`); + } + if (manifest.bundledDependencies !== undefined || manifest.bundleDependencies !== undefined) { + fail("published registry dependencies must not be bundled"); + } + for (const [name, coordinate] of Object.entries(manifest.dependencies ?? {})) { + if (typeof coordinate !== "string" || /^(?:file|link|workspace):/u.test(coordinate)) { + fail(`runtime dependency ${name} is not a registry coordinate`); + } + } + for (const [location, entry] of Object.entries(lock.packages ?? {})) { + if (location === "") continue; + if (entry?.link === true || (typeof entry?.resolved === "string" && /^(?:file|link):/u.test(entry.resolved))) { + fail(`package lock contains a checkout-relative dependency at ${location}`); + } + } + const locked = lock.packages?.[`node_modules/${STELE}`]; + const expected = `https://registry.npmjs.org/@noopolis/stele/-/stele-${STELE_VERSION}.tgz`; + if (!locked || locked.version !== STELE_VERSION || locked.resolved !== expected) { + fail(`${STELE} lock entry must resolve to the exact npm registry tarball`); + } + if (typeof locked.integrity !== "string" || !locked.integrity.startsWith("sha512-")) { + fail(`${STELE} registry lock is missing sha512 integrity`); + } + const installed = path.join(packageRoot, "node_modules", STELE); + if ((await lstat(installed)).isSymbolicLink()) { + fail(`${STELE} must be physically installed; source-checkout links are rejected`); + } + if ((await readJson(path.join(installed, "package.json"))).version !== STELE_VERSION) { + fail(`${STELE} installed version drifted`); + } + return expected; +}; + +const assertPackedManifest = (manifest) => { + if (manifest.version !== "0.0.2") fail("packed Simfile version drifted"); + if (manifest.dependencies?.[STELE] !== STELE_VERSION) { + fail(`packed ${STELE} coordinate drifted`); + } + if (manifest.bundledDependencies !== undefined || manifest.bundleDependencies !== undefined) { + fail("packed manifest unexpectedly bundles registry dependencies"); + } + for (const [name, coordinate] of Object.entries(manifest.dependencies ?? {})) { + if (typeof coordinate === "string" && /^(?:file|link|workspace):/u.test(coordinate)) { + fail(`packed dependency ${name} retains a checkout-relative coordinate`); + } + } +}; + +const dependencyRoot = async (installRoot, installedRoot) => { + for (const candidate of [ + path.join(installedRoot, "node_modules", STELE), + path.join(installRoot, "node_modules", STELE) + ]) { + try { + await lstat(candidate); + return candidate; + } catch (error) { + if (error?.code !== "ENOENT") throw error; + } + } + fail(`${STELE} was not installed from the packed Simfile dependency graph`); +}; + +const assertInstalledClosure = async (installRoot, manifest, tarballPath) => { + await writeFile(path.join(installRoot, "package.json"), "{\"private\":true}\n", "utf8"); + await run("npm", [ + "install", "--ignore-scripts", "--no-audit", "--no-fund", "--no-package-lock", + "--registry=https://registry.npmjs.org", tarballPath + ], installRoot); + const installedRoot = path.join(installRoot, "node_modules", manifest.name); + if ((await lstat(installedRoot)).isSymbolicLink()) fail("Simfile installed as a source link"); + const simfile = await import(pathToFileURL(path.join(installedRoot, "dist/index.js")).href); + if (typeof simfile.parseSimfileSource !== "function") fail("installed Simfile public import is incomplete"); + + const steleRoot = await dependencyRoot(installRoot, installedRoot); + if ((await lstat(steleRoot)).isSymbolicLink()) fail(`${STELE} installed as a source link`); + const steleManifest = await readJson(path.join(steleRoot, "package.json")); + if (steleManifest.version !== STELE_VERSION) fail(`${STELE} installed version drifted`); + const steleImport = steleManifest.exports?.["."]?.import; + if (typeof steleImport !== "string" || !steleImport.startsWith("./")) { + fail(`${STELE} does not expose a package-relative ESM entrypoint`); + } + const installRealRoot = await realpath(installRoot); + const steleRealPath = await realpath(path.resolve(steleRoot, steleImport)); + if (!steleRealPath.startsWith(`${installRealRoot}${path.sep}`)) { + fail(`${STELE} resolved outside the isolated install`); + } + const stele = await import(pathToFileURL(steleRealPath).href); + if (typeof stele.parseCausalJsonl !== "function") fail(`${STELE} runtime import is incomplete`); + + const executable = path.join(installRoot, "node_modules", ".bin", "simfile"); + const help = await run(executable, ["--help"], installRoot, { + ...process.env, + PATH: `${path.dirname(executable)}${path.delimiter}${process.env.PATH ?? ""}` + }); + if (!help.stdout.startsWith("Usage:\n") || !help.stdout.includes("simfile run ")) { + fail("installed Simfile executable did not invoke the CLI entrypoint"); + } + const importProbe = path.join(installRoot, "import-cli.mjs"); + const installedCli = path.join(installedRoot, manifest.bin.simfile); + await writeFile(importProbe, [ + `await import(${JSON.stringify(pathToFileURL(installedCli).href)});`, + 'process.stdout.write("import-only-ok\\n");', + "" + ].join("\n"), "utf8"); + const imported = await run(process.execPath, [importProbe], installRoot); + if (imported.stdout !== "import-only-ok\n" || imported.stderr !== "") { + fail("importing the installed CLI produced entrypoint side effects"); + } + return path.relative(installRealRoot, steleRealPath); +}; + +const main = async () => { + const manifest = await readJson(path.join(packageRoot, "package.json")); + const lock = await readJson(path.join(packageRoot, "package-lock.json")); + const steleRegistryTarball = await assertRegistrySource(manifest, lock); + const temporaryRoot = await mkdtemp(path.join(os.tmpdir(), "simfile-closure-")); + try { + const packDirectory = path.join(temporaryRoot, "pack"); + const installRoot = path.join(temporaryRoot, "install"); + await Promise.all([ + mkdir(packDirectory, { recursive: true }), + mkdir(installRoot, { recursive: true }) + ]); + const packed = parseSinglePack((await run( + "npm", ["pack", "--json", "--pack-destination", packDirectory], packageRoot + )).stdout); + const tarballPath = path.join(packDirectory, packed.filename); + const packedBytes = await readFile(tarballPath); + const integrity = `sha512-${createHash("sha512").update(packedBytes).digest("base64")}`; + const shasum = createHash("sha1").update(packedBytes).digest("hex"); + if (packed.integrity !== integrity || packed.shasum !== shasum) { + fail("npm pack manifest integrity does not match the tarball bytes"); + } + const entries = packed.files.map((entry) => entry.path); + if ((packed.bundled?.length ?? 0) !== 0) fail("npm pack unexpectedly bundled dependencies"); + if (entries.some((entry) => entry.startsWith("node_modules/") + || entry.startsWith("fixtures/") || entry.startsWith("runs/") + || entry.includes("ecosystem/") || entry.startsWith("vendor/"))) { + fail("packed tarball leaked a fixture, dependency, source checkout, or vendor archive"); + } + const packedManifest = JSON.parse((await run( + "tar", ["-xOf", tarballPath, "package/package.json"], packageRoot + )).stdout); + assertPackedManifest(packedManifest); + const steleResolved = await assertInstalledClosure(installRoot, manifest, tarballPath); + process.stdout.write(`${JSON.stringify({ + bundled: packed.bundled ?? [], + entries: packed.entryCount, + integrity: packed.integrity, + package: packed.id, + packed_file: packed.filename, + runtime_dependencies: packedManifest.dependencies, + stele_registry_tarball: steleRegistryTarball, + stele_resolved_inside_install: steleResolved + }, null, 2)}\n`); + } finally { + await rm(temporaryRoot, { force: true, recursive: true }); + } +}; + +await main(); diff --git a/tsconfig.build.json b/tsconfig.build.json index cf0eba4..76c678b 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -1,7 +1,8 @@ { "extends": "./tsconfig.json", "compilerOptions": { - "noEmit": false + "noEmit": false, + "stripInternal": true }, - "exclude": ["src/**/*.test.ts"] + "exclude": ["src/**/*.test.ts", "src/**/*.test-helper.ts"] } diff --git a/tsconfig.json b/tsconfig.json index 688c4c2..285d91e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -11,6 +11,7 @@ "declarationMap": true, "outDir": "dist", "rootDir": "src", + "lib": ["ES2023", "DOM", "DOM.Iterable"], "types": ["node"] }, "include": ["src/**/*.ts"] diff --git a/tsconfig.web.json b/tsconfig.web.json index 928c246..2b43c19 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -12,5 +12,8 @@ "noEmit": true, "types": ["vite/client", "react", "react-dom", "node"] }, - "include": ["web/src/**/*.ts", "web/src/**/*.tsx"] + "include": [ + "web/src/**/*.ts", + "web/src/**/*.tsx" + ] } diff --git a/web/public/models/ATTRIBUTION.md b/web/public/models/ATTRIBUTION.md new file mode 100644 index 0000000..43a37d5 --- /dev/null +++ b/web/public/models/ATTRIBUTION.md @@ -0,0 +1,14 @@ +# Model attribution + +## `man.glb` + +- Title: Man +- Creator: Quaternius +- Source: https://poly.pizza/m/HMnuH5geEG +- License: CC0 1.0 +- Gallery source: GlyphCSS `poly-pizza/man.glb` +- Source triangle count: 1,216 +- SHA-256: `dad8fa3ca2bc7760892f9ff47f544941179e50acb2aa772cf9f035154a37fc58` + +The file is copied into Simfile so the fixture remains self-contained and +does not fetch a model from a third-party origin at runtime. diff --git a/web/public/models/man.glb b/web/public/models/man.glb new file mode 100644 index 0000000000000000000000000000000000000000..938feebca107e40a6e6d9a5a24856c65ff07a41a GIT binary patch literal 493196 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 z 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 literal 0 HcmV?d00001 diff --git a/web/src/chrome/AGENTS.md b/web/src/chrome/AGENTS.md index 7ae284d..608ce7c 100644 --- a/web/src/chrome/AGENTS.md +++ b/web/src/chrome/AGENTS.md @@ -10,9 +10,13 @@ rather than living inside any one of them. event-density ticks, and a readout naming the event at the current cursor. Drives `../store/timeline.ts`'s cursor directly; every other pane (map, chat, minds rail, storyline portal) reacts to that same cursor - rather than owning its own clock. Playback respects - `prefers-reduced-motion` (disables the auto-advance interval rather than - silently ticking). Increment 3 adds a phase-band row and a `tick N · + rather than owning its own clock. Its optional `tickDurationMs`/ + `firstTick`/`lastTick` props make the trace's corroborated simulated time + the playback master clock while the cursor remains event-indexed. When a + trace declares no usable timing, playback retains the pre-existing + one-step-per-second pacing. + Playback respects `prefers-reduced-motion` (disables auto-advance rather + than silently ticking). Increment 3 adds a phase-band row and a `tick N · phase` readout prefix (both from `clockModel.ts`'s `derivePhaseBands`/ `currentClockReadout`, over the timeline's own `viewClass: "clock"` events — empty/absent for a run with no world clock stream) and, when @@ -23,11 +27,24 @@ rather than living inside any one of them. unit-testable without rendering React (this repo's test runner only picks up `.test.ts`, not `.test.tsx`): `derivePhaseBands`, `currentClockReadout`, `spreadDotEvents`. +- `playbackCadence.ts` — pure real-time cadence and elapsed-wall-time cursor + derivations, including the watchable one-step-per-second floor. Its + `playbackTickAtCursor` export is the single owner of the app's `cursor -> + tick` mapping. A sample-backed spatial-only axis uses each + `spatial.sample` row's explicit `payload.tick`, including irregular sample + gaps; ordinary causal timelines retain the corroborated timestamp path. It + answers in whole world ticks by rounding away the + fractional artifact caused when ledger ISO timestamps truncate simulated + time to whole milliseconds; its private continuous value remains available + to elapsed-time cursor search. It contains no React, DOM, or timer code so + sub-frame playback is deterministic in tests. ## Rules -- This bar is the only place a `setInterval`-driven playback loop may live; - other components read the cursor, they do not advance it on a timer. +- This bar is the only place the run-replay `requestAnimationFrame` playback + loop may live; other components read the cursor, they do not advance it on + a timer. Cadence math belongs in `playbackCadence.ts`, never inline in the + component. - Every control here must act through `../store/timeline.ts`'s exported actions — no local shadow state for cursor/playing/speed. - Named exports only. Keep files under 400 lines. diff --git a/web/src/chrome/ScrubBar.tsx b/web/src/chrome/ScrubBar.tsx index a5118df..d812473 100644 --- a/web/src/chrome/ScrubBar.tsx +++ b/web/src/chrome/ScrubBar.tsx @@ -7,11 +7,34 @@ import { setCursor, setSpeed, stepBy, + timelineStore, togglePlay, useTimelineStore, type TimelineEvent, } from "../store/timeline.js"; import { currentClockReadout, derivePhaseBands, spreadDotEvents } from "./clockModel.js"; +import { + cursorAtElapsed, + derivePlaybackCadence, + playbackCadenceReadout, + playbackTickAtCursor, + playbackTickAtElapsed, +} from "./playbackCadence.js"; + +export interface ScrubBarProps { + readonly firstTick?: number; + readonly lastTick?: number; + readonly onPresentationTickChange?: (tick: number | undefined) => void; + readonly presentationTickAtCursor?: (cursor: number) => number | undefined; + readonly seedSpreadEventIds?: ReadonlySet; + readonly tickDurationMs?: number; +} + +interface PlaybackAnchor { + readonly cursor: number; + readonly presentationTick?: number; + readonly timestampMs: number; +} const prefersReducedMotion = (): boolean => typeof window !== "undefined" && window.matchMedia?.("(prefers-reduced-motion: reduce)").matches === true; @@ -24,7 +47,7 @@ const formatClock = (recordedAt: string): string => { const readoutFor = (event: TimelineEvent | undefined, cursor: number, max: number): string => { if (!event) return `step ${cursor}/${max}`; - return `step ${cursor}/${max} · ${formatClock(event.recordedAt)} · ${event.authority}:${event.streamId}:${event.viewClass}`; + return `step ${cursor}/${max} · ${formatClock(event.recordedAt)}`; }; /** A stable-ish color per phase name, so adjacent bands read as distinct without hardcoding any specific phase's name (`morning`/`workday`/... are fixture-owned, not chrome-owned). */ @@ -45,21 +68,38 @@ const phaseBandTone = (phase: string, index: number): number => { * Increment 3: when the run has a world `clock.sync` stream, a row of * phase bands renders under the track (`derivePhaseBands`) and the readout * gains a `tick N · phase` prefix (`currentClockReadout`) — both derive - * from real `clock.sync` payloads, so a run with no world stream (e.g. - * `office-sim-golden`) simply renders neither. `seedSpreadEventIds`, when + * from real `clock.sync` payloads, so a run with no world stream simply + * renders neither. `seedSpreadEventIds`, when * passed, marks the real `seed_spread` events on the track as distinct * "spread dots" (`spreadDotEvents`) — omitted (no dots) for a run with no * seed declaration. */ -export function ScrubBar({ seedSpreadEventIds }: { seedSpreadEventIds?: ReadonlySet }) { +export function ScrubBar({ + firstTick, + lastTick, + onPresentationTickChange, + presentationTickAtCursor, + seedSpreadEventIds, + tickDurationMs, +}: ScrubBarProps) { const { timeline, cursor, playing, speed } = useTimelineStore(); const reducedMotion = useMemo(prefersReducedMotion, []); - const timerRef = useRef | null>(null); - // Read the latest cursor inside the interval without re-creating it every tick. - const cursorRef = useRef(cursor); - cursorRef.current = cursor; + const animationFrameRef = useRef(null); + const anchorRef = useRef(null); + const lastWrittenCursorRef = useRef(null); const max = maxCursor(timeline); + const cadence = useMemo( + () => derivePlaybackCadence({ + eventCount: timeline?.events.length ?? 0, + events: timeline?.events, + firstTick, + lastTick, + tickDurationMs, + speed, + }), + [timeline, firstTick, lastTick, tickDurationMs, speed], + ); const currentEvent = timeline?.events[cursor]; const phaseBands = useMemo(() => (timeline ? derivePhaseBands(timeline.events) : []), [timeline]); const clockReadout = useMemo(() => (timeline ? currentClockReadout(timeline.events, cursor) : undefined), [timeline, cursor]); @@ -69,26 +109,77 @@ export function ScrubBar({ seedSpreadEventIds }: { seedSpreadEventIds?: Readonly ); useEffect(() => { - if (timerRef.current !== null) { - clearInterval(timerRef.current); - timerRef.current = null; - } + if (animationFrameRef.current !== null) cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + anchorRef.current = null; + lastWrittenCursorRef.current = null; if (!playing || reducedMotion || !timeline) return; - timerRef.current = setInterval(() => { - const nextCursor = cursorRef.current + 1; - if (nextCursor > max) { + const advance = (timestampMs: number): void => { + animationFrameRef.current = null; + const storeCursor = timelineStore.getSnapshot().cursor; + let anchor = anchorRef.current; + + if (anchor === null || (lastWrittenCursorRef.current !== null && storeCursor !== lastWrittenCursorRef.current)) { + anchor = { + cursor: storeCursor, + presentationTick: presentationTickAtCursor?.(storeCursor), + timestampMs, + }; + anchorRef.current = anchor; + } + + const nextCursor = cursorAtElapsed(anchor.cursor, timestampMs - anchor.timestampMs, cadence, max); + onPresentationTickChange?.( + playbackTickAtElapsed( + anchor.cursor, + timestampMs - anchor.timestampMs, + cadence, + anchor.presentationTick, + lastTick, + ), + ); + if (nextCursor !== lastWrittenCursorRef.current) { + setCursor(nextCursor); + lastWrittenCursorRef.current = nextCursor; + } + + if (nextCursor >= max) { + anchorRef.current = null; + lastWrittenCursorRef.current = null; togglePlay(); return; } - setCursor(nextCursor); - }, Math.max(50, 1000 / speed)); + + animationFrameRef.current = requestAnimationFrame(advance); + }; + + animationFrameRef.current = requestAnimationFrame(advance); return () => { - if (timerRef.current !== null) clearInterval(timerRef.current); + if (animationFrameRef.current !== null) cancelAnimationFrame(animationFrameRef.current); + animationFrameRef.current = null; + anchorRef.current = null; + lastWrittenCursorRef.current = null; }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [playing, speed, reducedMotion, timeline, max]); + }, [ + playing, + reducedMotion, + timeline, + max, + cadence, + lastTick, + onPresentationTickChange, + presentationTickAtCursor, + ]); + + useEffect(() => { + if (!playing) { + onPresentationTickChange?.( + presentationTickAtCursor?.(cursor) ?? playbackTickAtCursor(cadence, cursor), + ); + } + }, [playing, cursor, cadence, onPresentationTickChange, presentationTickAtCursor]); if (!timeline) { return ( @@ -99,6 +190,7 @@ export function ScrubBar({ seedSpreadEventIds }: { seedSpreadEventIds?: Readonly } const densityTicks = timeline.events.filter((_, index) => index % Math.max(1, Math.floor(max / 120) || 1) === 0); + const cadenceReadout = playbackCadenceReadout(cadence); return (

); diff --git a/web/src/chrome/playbackCadence.test.ts b/web/src/chrome/playbackCadence.test.ts new file mode 100644 index 0000000..fcd1fce --- /dev/null +++ b/web/src/chrome/playbackCadence.test.ts @@ -0,0 +1,431 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { + cursorAtElapsed, + derivePlaybackCadence, + playbackCadenceReadout, + playbackTickAtCursor, + playbackTickAtElapsed, + type PlaybackCadence, + type PlaybackCadenceInput, +} from "./playbackCadence.js"; + +const measuredRunInput = (speed: number): PlaybackCadenceInput => ({ + eventCount: 202, + firstTick: 0, + lastTick: 200, + tickDurationMs: 20, + speed, +}); + +const frameWalk = (cadence: PlaybackCadence, durationMs: number, maxCursor = 201): number[] => { + const originMs = 12_345.678; + const cursors: number[] = []; + for (let timestampMs = originMs; timestampMs <= originMs + durationMs; timestampMs += 16.6667) { + cursors.push(cursorAtElapsed(0, timestampMs - originMs, cadence, maxCursor)); + } + cursors.push(cursorAtElapsed(0, durationMs, cadence, maxCursor)); + return cursors; +}; + +const assertMonotone = (values: readonly number[]): void => { + for (let index = 1; index < values.length; index += 1) { + assert.ok(values[index]! >= values[index - 1]!); + } +}; + +describe("derivePlaybackCadence", () => { + /** + * B146 acceptance numbers come from the measured served 200-tick dynamics + * run: 202 timeline events, 201 samples over ticks 0..200, and a declared + * 20 ms tick. + */ + it("plays the measured run in its declared four-second simulated duration", () => { + const cadence = derivePlaybackCadence(measuredRunInput(1)); + + assert.equal(cadence.cursorsPerSecond, 50.25); + assert.equal(cadence.declaredTiming, true); + assert.equal(cadence.realTime, true); + assert.equal(cadence.realDurationMs, 4_000); + assert.equal(playbackCadenceReadout(cadence), "50 steps/s · real time"); + + const cursors = frameWalk(cadence, 4_020); + assert.ok(cursorAtElapsed(0, 3_900, cadence, 201) < 201); + assert.equal(cursorAtElapsed(0, 4_020, cadence, 201), 201); + assertMonotone(cursors); + }); + + /** + * The speed cases reuse the measured B146 run shape above; 0.5x, 4x, and + * 8x are the scrub bar's existing multiplier options. + */ + it("applies speed as a real-time multiplier, including multi-step frames", () => { + assert.equal(derivePlaybackCadence(measuredRunInput(0.5)).realDurationMs, 8_000); + assert.equal(derivePlaybackCadence(measuredRunInput(4)).realDurationMs, 1_000); + + const eightTimes = derivePlaybackCadence(measuredRunInput(8)); + assert.equal(eightTimes.cursorsPerSecond, 402); + assert.equal(eightTimes.realDurationMs, 500); + const cursors = frameWalk(eightTimes, 520); + assert.equal(cursors.at(-1), 201); + assert.ok(cursors.every((cursor) => cursor <= 201)); + assertMonotone(cursors); + }); + + it("preserves existing one-step-per-second base pacing without declared timing", () => { + const cadence = derivePlaybackCadence({ eventCount: 20, speed: 1 }); + + for (const speed of [0.5, 1, 2, 4, 8]) { + assert.equal(derivePlaybackCadence({ eventCount: 20, speed }).cursorsPerSecond, speed); + } + assert.equal(cadence.cursorsPerSecond, 1); + assert.equal(cadence.declaredTiming, false); + assert.equal(cadence.realTime, false); + assert.equal(playbackCadenceReadout(cadence), "1.0 steps/s"); + assert.equal(cursorAtElapsed(0, 1_000, cadence, 19), 1); + assert.equal(cursorAtElapsed(0, 4_000, cadence, 19), 4); + }); + + it("floors a slow-tick trace to a watchable rate", () => { + const cadence = derivePlaybackCadence({ + eventCount: 5_000, + firstTick: 0, + lastTick: 480, + tickDurationMs: 60_000, + speed: 1, + }); + + assert.equal(cadence.cursorsPerSecond, 1); + assert.ok(cadence.cursorsPerSecond >= 1); + assert.equal(cadence.declaredTiming, true); + assert.equal(cadence.realTime, false); + assert.equal(playbackCadenceReadout(cadence), "1.0 steps/s · faster than real time"); + }); + + it("floors a one-hour tick to one watchable step per second times speed", () => { + const cadence = derivePlaybackCadence({ + eventCount: 61, + firstTick: 0, + lastTick: 60, + tickDurationMs: 3_600_000, + speed: 4, + }); + + assert.equal(cadence.cursorsPerSecond, 4); + assert.equal(Number.isFinite(cadence.cursorsPerSecond), true); + assert.equal(cadence.declaredTiming, true); + assert.equal(cadence.realTime, false); + }); + + it("keeps a one-millisecond tick finite and lands on the final cursor", () => { + const maxCursor = 1_000; + const cadence = derivePlaybackCadence({ + eventCount: maxCursor + 1, + firstTick: 0, + lastTick: maxCursor, + tickDurationMs: 1, + speed: 8, + }); + const cursors = frameWalk(cadence, 140, maxCursor); + + assert.equal(cadence.cursorsPerSecond, 8_000); + assert.equal(Number.isFinite(cadence.cursorsPerSecond), true); + assert.ok(cadence.cursorsPerSecond > 0); + assert.equal(cursors.at(-1), maxCursor); + assert.ok(cursors.every((cursor) => cursor <= maxCursor)); + assertMonotone(cursors); + }); + + /** + * These zero, negative, non-finite, and zero-span values are the boundary + * inputs enumerated by the B146 cadence contract. + */ + it("returns a finite positive rate for every degenerate input", () => { + const inputs: PlaybackCadenceInput[] = [ + { eventCount: 0, speed: 1 }, + { eventCount: 1, speed: 1 }, + { eventCount: 202, firstTick: 7, lastTick: 7, tickDurationMs: 20, speed: 1 }, + { ...measuredRunInput(1), tickDurationMs: 0 }, + { ...measuredRunInput(1), tickDurationMs: -20 }, + { ...measuredRunInput(1), tickDurationMs: Number.NaN }, + { ...measuredRunInput(1), tickDurationMs: Number.POSITIVE_INFINITY }, + { ...measuredRunInput(1), speed: 0 }, + { ...measuredRunInput(1), speed: -1 }, + { ...measuredRunInput(1), speed: Number.NaN }, + ]; + + for (const input of inputs) { + const rate = derivePlaybackCadence(input).cursorsPerSecond; + assert.equal(Number.isFinite(rate), true); + assert.equal(Number.isNaN(rate), false); + assert.ok(rate > 0); + } + }); +}); + +describe("cursorAtElapsed", () => { + it("returns stable values for no-op frames and changes once per second", () => { + const cadence = derivePlaybackCadence({ eventCount: 20, speed: 1 }); + const cursors = Array.from( + { length: 121 }, + (_, frame) => cursorAtElapsed(0, frame / 60 * 1_000, cadence, 19), + ); + const changes = cursors.flatMap((cursor, frame) => ( + frame > 0 && cursor !== cursors[frame - 1] ? [[frame, cursor] as const] : [] + )); + + assert.deepEqual([...new Set(cursors.slice(0, 60))], [0]); + assert.deepEqual([...new Set(cursors.slice(60, 120))], [1]); + assert.deepEqual(changes, [[60, 1], [120, 2]]); + }); + + /** + * The 120 re-anchor is the specified mid-pass user-scrub case; the other + * values exercise the elapsed-time guards and max-cursor contract directly. + */ + it("clamps, stays monotone, ignores invalid elapsed time, and honors re-anchors", () => { + const cadence = derivePlaybackCadence(measuredRunInput(1)); + + assert.equal(cursorAtElapsed(190, 1_000, cadence, 201), 201); + assert.equal(cursorAtElapsed(120, 0, cadence, 201), 120); + assert.equal(cursorAtElapsed(120, -5, cadence, 201), 120); + assert.equal(cursorAtElapsed(120, Number.NaN, cadence, 201), 120); + + const elapsed = [1, 16.6667, 100, 500, 1_000, 4_020]; + const cursors = elapsed.map((elapsedMs) => cursorAtElapsed(0, elapsedMs, cadence, 201)); + assertMonotone(cursors); + + assert.equal(cursorAtElapsed(120, 0, cadence, 201), 120); + assert.equal(cursorAtElapsed(120, 100, cadence, 201), 125); + }); +}); + +describe("simulated-time master clock with many events per tick", () => { + const maxCursor = 18_001; + const lastTick = 2_000; + const tickDurationMs = 20; + const events = Array.from({ length: maxCursor + 1 }, (_, index) => { + const tick = Math.min(Math.floor(index / 9), lastTick); + return { recordedAt: new Date(tick * tickDurationMs).toISOString() }; + }); + const manyEventsInput = (speed: number): PlaybackCadenceInput => ({ + eventCount: events.length, + events, + firstTick: 0, + lastTick, + tickDurationMs, + speed, + }); + + it("derives duration and endpoint ticks from the trace's timestamps", () => { + const cadence = derivePlaybackCadence(manyEventsInput(1)); + const halfSpeed = derivePlaybackCadence(manyEventsInput(0.5)); + + assert.equal(cadence.realDurationMs, 40_000); + assert.equal(halfSpeed.realDurationMs, 80_000); + assert.equal(playbackTickAtCursor(cadence, 0), 0); + assert.equal(playbackTickAtCursor(cadence, maxCursor), lastTick); + }); + + it("returns whole ticks across millisecond-truncated ledger timestamps", () => { + const truncatedLastTick = 1_000; + const ledgerTickDurationMs = 20; + const ledgerEvents = Array.from({ length: truncatedLastTick + 1 }, (_, tick) => ({ + recordedAt: new Date(tick * 0.02 * 1_000).toISOString(), + })); + const cadence = derivePlaybackCadence({ + eventCount: ledgerEvents.length, + events: ledgerEvents, + firstTick: 0, + lastTick: truncatedLastTick, + tickDurationMs: ledgerTickDurationMs, + speed: 1, + }); + + assert.equal(Date.parse(ledgerEvents[803]!.recordedAt) / ledgerTickDurationMs, 802.95); + for (let cursor = 0; cursor <= truncatedLastTick; cursor += 1) { + const tick = playbackTickAtCursor(cadence, cursor); + assert.equal(tick, cursor); + assert.equal(Number.isInteger(tick), true); + } + assert.equal(playbackTickAtCursor(cadence, 0), 0); + assert.equal(playbackTickAtCursor(cadence, truncatedLastTick), truncatedLastTick); + }); + + it("keeps the bar and spatial motion together through the end", () => { + const cadence = derivePlaybackCadence(manyEventsInput(1)); + const finalCursor = cursorAtElapsed(0, 40_000, cadence, maxCursor); + + assert.ok(cursorAtElapsed(0, 39_000, cadence, maxCursor) < maxCursor); + assert.equal(finalCursor, maxCursor); + assert.equal(playbackTickAtCursor(cadence, finalCursor), lastTick); + }); + + it("tracks simulated time at the selected playback speed", () => { + const cadence = derivePlaybackCadence(manyEventsInput(1)); + const halfSpeed = derivePlaybackCadence(manyEventsInput(0.5)); + const cursor = cursorAtElapsed(0, 4_480, cadence, maxCursor); + const halfSpeedCursor = cursorAtElapsed(0, 4_480, halfSpeed, maxCursor); + + assert.equal(playbackTickAtCursor(cadence, cursor), 224); + assert.equal(playbackTickAtCursor(halfSpeed, halfSpeedCursor), 112); + }); + + it("advances a continuous presentation tick between sparse events", () => { + const sparseEvents = Array.from({ length: 5 }, (_, index) => ({ + recordedAt: new Date(index * 1_000).toISOString(), + })); + const cadence = derivePlaybackCadence({ + eventCount: sparseEvents.length, + events: sparseEvents, + firstTick: 0, + lastTick: 200, + tickDurationMs: 20, + speed: 1, + }); + + assert.equal(cursorAtElapsed(0, 16, cadence, 4), 0); + assert.equal(playbackTickAtCursor(cadence, 0), 0); + assert.equal(playbackTickAtElapsed(0, 16, cadence), 0.8); + assert.equal(playbackTickAtElapsed(0, 500, cadence), 25); + assert.equal(playbackTickAtElapsed(0, 9_000, cadence), 200); + }); + + it("advances from a recorded world-tick anchor when wall time is not the world clock", () => { + const cadence = derivePlaybackCadence({ + eventCount: 288, + firstTick: 0, + lastTick: 6_051, + tickDurationMs: 20, + speed: 2, + }); + + assert.equal(playbackTickAtCursor(cadence, 68), undefined); + assert.equal(playbackTickAtElapsed(68, 16, cadence, 1_430, 6_051), 1_431.6); + assert.equal(playbackTickAtElapsed(68, 100_000, cadence, 1_430, 6_051), 6_051); + }); + + it("preserves a one-event-per-tick timestamped trace", () => { + const oneToOneEvents = Array.from({ length: 202 }, (_, index) => ({ + recordedAt: new Date(Math.min(index, 200) * tickDurationMs).toISOString(), + })); + const cadence = derivePlaybackCadence({ + eventCount: oneToOneEvents.length, + events: oneToOneEvents, + firstTick: 0, + lastTick: 200, + tickDurationMs, + speed: 1, + }); + const finalCursor = cursorAtElapsed(0, 4_000, cadence, 201); + + assert.equal(cadence.realDurationMs, 4_000); + assert.equal(finalCursor, 201); + assert.equal(playbackTickAtCursor(cadence, finalCursor), 200); + }); + + it("falls back when timestamps do not corroborate the declared tick span", () => { + const wallClockEvents = events.map((_, index) => ({ + recordedAt: new Date(index / maxCursor * 3 * 60 * 60 * 1_000).toISOString(), + })); + const cadence = derivePlaybackCadence({ + ...manyEventsInput(1), + events: wallClockEvents, + }); + const noEventsCadence = derivePlaybackCadence({ + ...manyEventsInput(1), + events: undefined, + }); + + assert.equal(playbackTickAtCursor(cadence, 1_000), undefined); + for (const elapsedMs of [1, 16, 1_000, 4_480, 40_000]) { + assert.equal( + cursorAtElapsed(0, elapsedMs, cadence, maxCursor), + cursorAtElapsed(0, elapsedMs, noEventsCadence, maxCursor), + ); + } + }); + + it("leaves a trace with no declared timing on event-uniform pacing", () => { + const cadence = derivePlaybackCadence({ + eventCount: events.length, + events, + speed: 1, + }); + + assert.equal(playbackTickAtCursor(cadence, 0), undefined); + assert.equal(cursorAtElapsed(0, 1_000, cadence, maxCursor), 1); + }); + + it("tracks simulated time when event density is non-uniform", () => { + const nonUniformEvents = Array.from({ length: 2_001 }, (_, tick) => ( + Array.from({ length: tick >= 1_000 && tick < 2_000 ? 17 : 1 }, () => ({ + recordedAt: new Date(tick * 20).toISOString(), + })) + )).flat(); + const nonUniformMaxCursor = nonUniformEvents.length - 1; + const input: PlaybackCadenceInput = { + eventCount: nonUniformEvents.length, + events: nonUniformEvents, + firstTick: 0, + lastTick: 2_000, + tickDurationMs: 20, + speed: 1, + }; + const cadence = derivePlaybackCadence(input); + const halfPassCursor = cursorAtElapsed(0, 20_000, cadence, nonUniformMaxCursor); + const fallback = derivePlaybackCadence({ ...input, events: undefined }); + const fallbackCursor = cursorAtElapsed(0, 20_000, fallback, nonUniformMaxCursor); + + assert.equal(playbackTickAtCursor(cadence, halfPassCursor), 1_000); + assert.notEqual(halfPassCursor, fallbackCursor); + assert.ok(playbackTickAtCursor(cadence, fallbackCursor)! > 1_400); + assert.equal(cursorAtElapsed(0, 40_000, cadence, nonUniformMaxCursor), nonUniformMaxCursor); + assert.equal(playbackTickAtCursor(cadence, nonUniformMaxCursor), 2_000); + assert.ok(cursorAtElapsed(0, 39_000, cadence, nonUniformMaxCursor) < nonUniformMaxCursor); + assertMonotone(frameWalk(cadence, 40_000, nonUniformMaxCursor)); + }); + + it("guards simulated time against a genuine two-second wall clock", async () => { + const cadence = derivePlaybackCadence(manyEventsInput(1)); + const start = performance.now(); + let elapsedMs = 0; + let finalCursor = 0; + + do { + await new Promise((resolve) => setTimeout(resolve, 16)); + elapsedMs = performance.now() - start; + finalCursor = cursorAtElapsed(0, elapsedMs, cadence, maxCursor); + } while (elapsedMs < 2_000); + + const finalTick = playbackTickAtCursor(cadence, finalCursor); + assert.notEqual(finalTick, undefined); + assert.ok(Math.abs(finalTick! - elapsedMs / tickDurationMs) <= 2); + }); +}); + +describe("spatial-sample master clock", () => { + it("keeps sparse sample playback aligned when the watchability floor wins", () => { + const events = [0, 100, 200].map((tick) => ({ + payload: { tick }, + recordedAt: `tick ${tick}`, + type: "spatial.sample", + })); + const cadence = derivePlaybackCadence({ + eventCount: events.length, + events, + firstTick: 0, + lastTick: 200, + speed: 1, + tickDurationMs: 20, + }); + + assert.equal(cadence.realTime, false); + assert.equal(cadence.realDurationMs, 2_000); + assert.equal(cursorAtElapsed(0, 999, cadence, 2), 0); + assert.equal(cursorAtElapsed(0, 1_000, cadence, 2), 1); + assert.equal(cursorAtElapsed(0, 2_000, cadence, 2), 2); + assert.equal(playbackTickAtElapsed(0, 2_000, cadence), 200); + }); +}); diff --git a/web/src/chrome/playbackCadence.ts b/web/src/chrome/playbackCadence.ts new file mode 100644 index 0000000..5e80f10 --- /dev/null +++ b/web/src/chrome/playbackCadence.ts @@ -0,0 +1,306 @@ +/** + * Playback keeps the cursor event-indexed because every pane already uses it + * as its key: ChatPane and MindsRail consume the `t <= cursor` prefix, + * clockModel bands are expressed in `t`, deep links persist a cursor, and + * `maxCursor(timeline)` is `events.length - 1`. Re-indexing by tick would + * change stored deep-link meaning, collapse distinct event positions, and + * remove the ability to step to one event; in the measured run it would + * collapse 18,002 cursor positions to 2,001 ticks. + * + * Instead, wall time advances simulated time at the trace's declared tick + * rate, and simulated time selects the cursor. Event-index movement is + * therefore intentionally non-uniform when event density is non-uniform. + * Each event's simulated time comes from its own real `recordedAt` value, + * never an invented ratio, and is trusted only when the final timestamp + * corroborates the trace's declared tick span. + */ + +interface SimulatedTimeIndex { + readonly derivedTicks: readonly number[]; + readonly speed: number; + readonly tickDurationMs: number; +} + +export interface PlaybackCadence { + /** Store-cursor steps per real second at the caller's speed. Always finite and > 0. */ + readonly cursorsPerSecond: number; + /** True when the trace supplied a usable tick duration and tick span. */ + readonly declaredTiming: boolean; + /** Real milliseconds for a full pass from cursor 0 to the last event. */ + readonly realDurationMs: number; + /** True when declared timing set the rate; false when the presentation floor won or timing is absent. */ + readonly realTime: boolean; + /** Sanitized playback multiplier used by the presentation clock. */ + readonly speed: number; + /** Positive declared tick duration when the trace supplies one. */ + readonly tickDurationMs?: number; + /** @internal Accepted event timestamps expressed as simulated ticks. */ + readonly simulatedTimeIndex?: SimulatedTimeIndex; +} + +export interface PlaybackCadenceInput { + readonly eventCount: number; + /** First/last tick of the trace's own spatial samples, when it has them. */ + readonly firstTick?: number; + readonly lastTick?: number; + /** The trace's DECLARED `tick_duration_ms`. Undefined when the trace declares none. */ + readonly tickDurationMs?: number; + readonly speed: number; + /** The trace's own events, in cursor order, for their recorded simulated timestamps. Optional: absent = the pre-existing event-uniform pacing. */ + readonly events?: readonly { + readonly payload?: unknown; + readonly recordedAt: string; + readonly type?: string; + }[]; +} + +const spatialSampleTicks = ( + events: readonly { readonly payload?: unknown; readonly type?: string }[], + firstTick: number, + lastTick: number, +): readonly number[] | undefined => { + const ticks = events.map((event) => { + if (event.type !== "spatial.sample" || typeof event.payload !== "object" + || event.payload === null || Array.isArray(event.payload)) return undefined; + const tick = (event.payload as { tick?: unknown }).tick; + return typeof tick === "number" && Number.isFinite(tick) ? tick : undefined; + }); + if (ticks.some((tick) => tick === undefined)) return undefined; + const values = ticks as number[]; + if (values[0] !== firstTick || values.at(-1) !== lastTick + || values.some((tick, index) => index > 0 && tick < values[index - 1]!)) { + return undefined; + } + return values; +}; + +const deriveSimulatedTimeIndex = ( + events: readonly { + readonly payload?: unknown; + readonly recordedAt: string; + readonly type?: string; + }[], + eventCount: number, + firstTick: number, + lastTick: number, + tickDurationMs: number, + speed: number, + explicitSpatialTicks?: readonly number[], +): SimulatedTimeIndex | undefined => { + if (events.length === 0 || events.length !== eventCount) return undefined; + + if (explicitSpatialTicks !== undefined) { + return { derivedTicks: explicitSpatialTicks, speed, tickDurationMs }; + } + + const recordedAtMs = events.map((event) => Date.parse(event.recordedAt)); + if (recordedAtMs.some((value) => !Number.isFinite(value))) return undefined; + for (let index = 1; index < recordedAtMs.length; index += 1) { + if (recordedAtMs[index]! < recordedAtMs[index - 1]!) return undefined; + } + + const originMs = recordedAtMs[0]!; + const derivedTicks = recordedAtMs.map( + (value) => firstTick + (value - originMs) / tickDurationMs, + ); + const derivedLastTick = derivedTicks[derivedTicks.length - 1]!; + if (Math.abs(derivedLastTick - lastTick) > 1) return undefined; + + return { derivedTicks, speed, tickDurationMs }; +}; + +/** + * Derives discrete replay-cursor pacing from the trace's declared simulated + * duration. One cursor per second is a presentation minimum for human + * watchability: advancing once per simulated minute or hour would leave the + * viewer showing no change for too long. The floor is not an assumption about + * any trace's tick scale. + * + * When a trace's declared tick is shorter than one animation frame, and more + * so at higher playback speeds, the choice is multi-step-per-frame advancement + * or interpolation. We deliberately advance multiple steps. The scrub cursor + * is a discrete "as of" selector over an event list, so there is no partial + * state between cursor 7 and cursor 8 for this layer to interpolate. Computing + * each frame's cursor directly from wall time makes a high step rate one store + * write per frame instead of a proportional catch-up loop. The corroborated + * timestamp path finds the cursor in O(log n); the event-uniform fallback is + * O(1). Events are not dropped because panes consume the complete `t <= + * cursor` prefix, not only the latest event. Continuous interpolation is + * intentionally delegated to the better-informed layer below: + * `../viewer/spatialObjectModel.ts`'s `spatialObjectAtPresentationTick` + * interpolates rendered position between authoritative samples, using each + * sample's recorded velocity as a Hermite tangent. This module answers only + * "what has happened by now?". + */ +export const derivePlaybackCadence = (input: PlaybackCadenceInput): PlaybackCadence => { + const { eventCount, events, firstTick, lastTick, tickDurationMs } = input; + let baseCursorsPerSecond = 1; + let realTimeCursorsPerSecond = 0; + let realTime = false; + const declaredTiming = ( + Number.isFinite(tickDurationMs) + && tickDurationMs !== undefined + && tickDurationMs > 0 + && Number.isFinite(firstTick) + && firstTick !== undefined + && Number.isFinite(lastTick) + && lastTick !== undefined + && lastTick - firstTick >= 1 + ); + + if (declaredTiming && eventCount >= 2) { + const simulatedDurationMs = (lastTick - firstTick) * tickDurationMs; + realTimeCursorsPerSecond = (eventCount - 1) / (simulatedDurationMs / 1_000); + const finiteRealTimeRate = Number.isFinite(realTimeCursorsPerSecond) + ? realTimeCursorsPerSecond + : realTimeCursorsPerSecond > 0 + ? Number.MAX_VALUE + : 0; + baseCursorsPerSecond = Math.max(1, finiteRealTimeRate); + realTime = realTimeCursorsPerSecond >= 1; + } + + const speed = Number.isFinite(input.speed) && input.speed > 0 ? input.speed : 1; + const multipliedRate = baseCursorsPerSecond * speed; + const cursorsPerSecond = Number.isFinite(multipliedRate) && multipliedRate > 0 + ? multipliedRate + : Number.MAX_VALUE; + const explicitSpatialTicks = ( + declaredTiming + && events !== undefined + && firstTick !== undefined + && lastTick !== undefined + ) + ? spatialSampleTicks(events, firstTick, lastTick) + : undefined; + // Sparse spatial samples can fall below the one-cursor-per-second + // watchability floor. Scale their simulated clock by the same factor as + // the cursor rate so the map and scrubber still arrive together. + const indexSpeed = explicitSpatialTicks !== undefined + && Number.isFinite(realTimeCursorsPerSecond) + && realTimeCursorsPerSecond > 0 + ? speed * baseCursorsPerSecond / realTimeCursorsPerSecond + : speed; + const simulatedTimeIndex = ( + declaredTiming + && (realTime || explicitSpatialTicks !== undefined) + && events !== undefined + && firstTick !== undefined + && lastTick !== undefined + && tickDurationMs !== undefined + ) + ? deriveSimulatedTimeIndex( + events, + eventCount, + firstTick, + lastTick, + tickDurationMs, + indexSpeed, + explicitSpatialTicks, + ) + : undefined; + + return { + cursorsPerSecond, + declaredTiming, + realDurationMs: Math.max(0, eventCount - 1) / cursorsPerSecond * 1_000, + realTime, + speed, + tickDurationMs: declaredTiming ? tickDurationMs : undefined, + simulatedTimeIndex, + }; +}; + +export const playbackCadenceReadout = (cadence: PlaybackCadence): string => { + const roundedCadence = cadence.cursorsPerSecond >= 10 + ? String(Math.round(cadence.cursorsPerSecond)) + : cadence.cursorsPerSecond.toFixed(1); + + if (!cadence.declaredTiming) return `${roundedCadence} steps/s`; + return cadence.realTime + ? `${roundedCadence} steps/s · real time` + : `${roundedCadence} steps/s · faster than real time`; +}; + +const continuousPlaybackTickAtCursor = ( + cadence: PlaybackCadence, + cursor: number, +): number | undefined => { + const index = cadence.simulatedTimeIndex; + if (!index) return undefined; + const wholeCursor = Number.isNaN(cursor) ? 0 : Math.floor(cursor); + const clampedCursor = wholeCursor <= 0 + ? 0 + : wholeCursor >= index.derivedTicks.length - 1 + ? index.derivedTicks.length - 1 + : wholeCursor; + return index.derivedTicks[clampedCursor]; +}; + +export const cursorAtElapsed = ( + anchorCursor: number, + elapsedMs: number, + cadence: PlaybackCadence, + maxCursor: number, +): number => { + if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return anchorCursor; + const index = cadence.simulatedTimeIndex; + if (index) { + const anchorTick = continuousPlaybackTickAtCursor(cadence, anchorCursor)!; + const targetTick = anchorTick + elapsedMs * index.speed / index.tickDurationMs; + let low = 0; + let high = index.derivedTicks.length; + while (low < high) { + const middle = low + Math.floor((high - low) / 2); + if (index.derivedTicks[middle]! <= targetTick) low = middle + 1; + else high = middle; + } + const elapsedCursor = low - 1; + return Math.max(anchorCursor, Math.min(maxCursor, elapsedCursor)); + } + const elapsedCursor = anchorCursor + Math.floor(elapsedMs * cadence.cursorsPerSecond / 1_000); + return Math.max(anchorCursor, Math.min(maxCursor, elapsedCursor)); +}; + +/** + * The app's single owner of the `cursor -> tick` mapping. When the trace has a + * corroborated clock, returns the whole simulated world tick where the + * record's own clock places the selected event: cursor 0 is the first event's + * tick and the final cursor is the final event's tick. Rounding removes only + * the fractional tick introduced when the ledger's ISO timestamp truncates + * simulated time to whole milliseconds; a discrete event cursor contains no + * sub-tick position to preserve. Consumers must not reimplement this join. + */ +export const playbackTickAtCursor = ( + cadence: PlaybackCadence, + cursor: number, +): number | undefined => { + const continuousTick = continuousPlaybackTickAtCursor(cadence, cursor); + return continuousTick === undefined ? undefined : Math.round(continuousTick); +}; + +/** + * Advances the presentation clock continuously between recorded events. + * Event panes remain cursor-indexed; spatial renderers can use this value on + * each animation frame so their existing interpolation is not frozen while + * the event cursor waits for the next record. + */ +export const playbackTickAtElapsed = ( + anchorCursor: number, + elapsedMs: number, + cadence: PlaybackCadence, + recordedAnchorTick?: number, + lastTick?: number, +): number | undefined => { + const index = cadence.simulatedTimeIndex; + const anchorTick = index + ? continuousPlaybackTickAtCursor(cadence, anchorCursor)! + : recordedAnchorTick; + const tickDurationMs = index?.tickDurationMs ?? cadence.tickDurationMs; + const speed = index?.speed ?? cadence.speed; + if (anchorTick === undefined || tickDurationMs === undefined) return undefined; + if (!Number.isFinite(elapsedMs) || elapsedMs <= 0) return anchorTick; + const maximum = index?.derivedTicks[index.derivedTicks.length - 1] ?? lastTick; + const tick = anchorTick + elapsedMs * speed / tickDurationMs; + return maximum === undefined ? tick : Math.min(maximum, tick); +}; diff --git a/web/src/main.tsx b/web/src/main.tsx index 4bb6207..061b0b4 100644 --- a/web/src/main.tsx +++ b/web/src/main.tsx @@ -2,8 +2,23 @@ import { createRoot } from "react-dom/client"; import { SimfileViewerApp } from "./viewer/App.js"; import { RunReplayShell } from "./viewer/RunReplayShell.js"; +import { loadViewerExtensions } from "./viewer/worldMapRendererCatalog.js"; import "./styles.css"; +const instrumentedGlobal = globalThis as typeof globalThis & { + __SIMFILE_PLAYBACK_DIAGNOSTICS__?: Record; + __glyphPerf?: { + dom: number[]; + polys: number[]; + raster: number[]; + }; +}; +instrumentedGlobal.__glyphPerf ??= { dom: [], polys: [], raster: [] }; +instrumentedGlobal.__SIMFILE_PLAYBACK_DIAGNOSTICS__ ??= { + positionCommits: 0, + positionFrames: 0, +}; + const root = document.getElementById("root"); if (!root) { @@ -19,16 +34,23 @@ if (!root) { */ const mount = async (): Promise => { const reactRoot = createRoot(root); + let mode: string | undefined; try { const state = await fetch("/api/state").then((response) => response.json() as Promise<{ mode?: string }>); - if (state.mode === "run-replay") { - reactRoot.render(); - return; - } + mode = state.mode; } catch { - // Fall through to the default world/live console below. + // The default console remains available when state discovery fails. + } + await loadViewerExtensions(); + if (mode === "run-replay" || mode === "run-live") { + reactRoot.render(); + return; } reactRoot.render(); }; -void mount(); +void mount().catch((error: unknown) => { + root.textContent = error instanceof Error + ? `Viewer extension failed: ${error.message}` + : "Viewer extension failed"; +}); diff --git a/web/src/portals/AGENTS.md b/web/src/portals/AGENTS.md index c52d0b8..7d57f90 100644 --- a/web/src/portals/AGENTS.md +++ b/web/src/portals/AGENTS.md @@ -10,10 +10,10 @@ a portal). (`agent:` | `room:` | `bank:` | `team:` | `variable:` refs all render through this same component — no per-kind portal code): opened by `../store/timeline.ts`'s `focusAndOpenPortal`, called from the map - (room anchor, agent body, and now the synthesized `team:` node), the chat - pane (author name), and the minds rail (bank header, per-agent - sub-header). Multiple portals can be open at once (`openPortals` is a - stack); `stackIndex` offsets each instance so they don't overlap. The + (room anchor or agent body), the chat pane (author name), and the minds + rail (bank header, per-agent sub-header). Multiple portals can be open at + once (`openPortals` is a stack); `stackIndex` offsets each instance so + they don't overlap. The branch that decides content is pure data presence (`../store/timeline.ts`'s `membraneForRef`): when `elementRef` names a real `RunTimeline.membranes` entry, it renders `MembraneView` (the @@ -35,7 +35,13 @@ a portal). `VariableSparkline` of the trajectory (`../viewer/variableModel.ts`'s `sampleAtTick`/`trajectoryUpToTick`, fed by the `variableSamples`/ `variableTick` props `RunReplayShell.tsx` passes to every open portal), - plus a scrollable tick→value list. The samples themselves are RECORDS + plus a scrollable tick→value list. + That `variableTick` is the run's one world tick as of the event cursor, + computed once through `../viewer/variableModel.ts`'s `tickAtCursor` -> + `../chrome/playbackCadence.ts`'s `playbackTickAtCursor`; the membrane branch + threads the same value through `StorylinePortal` -> `MembraneView` -> + `AsciiMap`, while `undefined` means the record states no time. The samples + themselves are RECORDS from `world/telemetry.json`, not `TimelineEvent`s, so they render here rather than through `StorylineRows`; the "caused" rule-firing/message events still come from the ordinary `eventsForElement` slice below them @@ -46,11 +52,16 @@ a portal). `membrane.interiorWorld`, the same `buildViewerWorld`/`AsciiMap` the outer map uses — one map renderer, not two), the interior room's `ChatPane` (scoped via its `roomFilter` prop to `membrane.interiorRooms`), and a - `MindsRail` filtered to `agentsForMembrane`/`banksForMembrane`. All three - read the store's cursor directly — no private clock. A "crossings" tab - keeps the membrane's own flat storyline available (the representative's - combined interior+exterior storyline — where interior meets exterior), - reusing `StorylineRows`. + `MindsRail` filtered to `agentsForMembrane`/`banksForMembrane`. Chat, minds, + and the crossings storyline read the store cursor. The interior map renders + the run's one world tick, computed once by `RunReplayShell` through + `variableModel.tickAtCursor` -> `playbackCadence.playbackTickAtCursor` and + threaded `StorylinePortal` -> `MembraneView` -> `AsciiMap`; `undefined` + means the record states no time. The cursor is an event index and must never + be passed as a map tick. This shared wiring keeps the portal free of a + private clock. A "crossings" tab keeps the membrane's own flat storyline + available (the representative's combined interior+exterior storyline — + where interior meets exterior), reusing `StorylineRows`. - `StorylineRows.tsx` — the shared vertical "now"-lined event-row list, split out of `StorylinePortal.tsx` so both the flat leaf rendering and `MembraneView`'s "crossings" tab use one row renderer, never two copies of diff --git a/web/src/portals/MembraneView.test.ts b/web/src/portals/MembraneView.test.ts new file mode 100644 index 0000000..f428e99 --- /dev/null +++ b/web/src/portals/MembraneView.test.ts @@ -0,0 +1,283 @@ +import assert from "node:assert/strict"; +import { beforeEach, describe, it } from "node:test"; + +import * as React from "react"; +import { createElement } from "react"; +import { renderToStaticMarkup } from "react-dom/server"; + +import { derivePlaybackCadence } from "../chrome/playbackCadence.js"; +import { + loadTimeline, + resetTimelineStoreForTests, + setCursor, + type RunTimeline, + type RunTimelineMembrane, + type TimelineEvent, +} from "../store/timeline.js"; +import { AsciiMap } from "../viewer/AsciiMap.js"; +import { defaultRenderSettings } from "../viewer/renderSettings.js"; +import type { ViewerContractTrace } from "../viewer/types.js"; +import { tickAtCursor } from "../viewer/variableModel.js"; +import { buildViewerWorld, viewerSkins } from "../viewer/worldModel.js"; +import { MembraneView } from "./MembraneView.js"; + +// The root tsconfig.json declares no `jsx`, so tsx transpiles `.tsx` with the +// classic runtime and the emitted `React.createElement` resolves against the +// global at render time. The vite build uses tsconfig.web.json's automatic +// runtime and is unaffected. P3 note: giving the root tsconfig `"jsx": +// "react-jsx"` would remove the need for this line; out of B210's scope. +(globalThis as Record).React = React; + +const measuredTickDurationMs = 50; +const measuredLastTick = 2_000; +const eventsPerTick = 9; +const measuredEventCount = measuredLastTick * eventsPerTick + 2; + +const roomARef = "room:inner:room-a"; +const roomBRef = "room:inner:room-b"; +const agentRef = "agent:traveler"; + +const interiorTrace = ({ + lastPresenceTick, + presenceStep, + tickDurationMs, + withSpatialSpan, +}: { + lastPresenceTick: number; + presenceStep: number; + tickDurationMs: number; + withSpatialSpan: boolean; +}): ViewerContractTrace => ({ + version: "viewer.trace.v1", + run_id: "neutral-record", + run_name: "neutral-record", + rooms: [ + { + id: "room-a", + label: "Room A", + members: ["traveler"], + scene: [-7, 0, 0], + scope: roomARef, + }, + { + id: "room-b", + label: "Room B", + members: ["traveler"], + scene: [7, 0, 0], + scope: roomBRef, + }, + ], + corridors: [], + agents: [{ id: "traveler", label: "Traveler", scope: agentRef }], + presence: Array.from( + { length: Math.floor(lastPresenceTick / presenceStep) + 1 }, + (_, index) => ({ + actor: "traveler", + room: index % 2 === 0 ? "room-a" : "room-b", + tick: index * presenceStep, + type: "presence.arrived" as const, + }), + ), + ledger_facts: [], + signals: [], + spatial_samples: withSpatialSpan + ? [ + { occupancy: {}, tick: 0, transit: [] }, + { occupancy: {}, tick: measuredLastTick, transit: [] }, + ] + : undefined, + tick_duration_ms: tickDurationMs, +}); + +const membraneFor = (trace: ViewerContractTrace, ref: string): RunTimelineMembrane => ({ + ref, + label: "Interior", + representative: agentRef, + interiorRooms: [roomARef, roomBRef], + members: [agentRef], + interiorWorld: trace, +}); + +const eventAt = (t: number, tick: number, recordedAt: string): TimelineEvent => ({ + t, + eventId: `event-${t}`, + authority: "neutral", + streamId: "neutral-stream", + seq: t + 1, + type: "record.observed", + viewClass: "other", + recordedAt, + subjects: [], + causes: [], + payload: { value: tick % 3 }, +}); + +const timelineFor = ( + runId: string, + events: TimelineEvent[], + membrane: RunTimelineMembrane, +): RunTimeline => ({ + version: "simfile.run-timeline.v1", + runId, + events, + elements: [ + { ref: membrane.ref, kind: "team", label: membrane.label }, + { ref: roomARef, kind: "room", label: "Room A" }, + { ref: roomBRef, kind: "room", label: "Room B" }, + { ref: agentRef, kind: "agent", label: "Traveler" }, + ], + membranes: [membrane], +}); + +const buildMeasuredFixture = () => { + const trace = interiorTrace({ + lastPresenceTick: 1_950, + presenceStep: 50, + tickDurationMs: measuredTickDurationMs, + withSpatialSpan: true, + }); + const membrane = membraneFor(trace, "membrane:measured"); + const events: TimelineEvent[] = []; + const expectedTicks: number[] = []; + const append = (tick: number): void => { + expectedTicks.push(tick); + events.push(eventAt(events.length, tick, new Date(tick * trace.tick_duration_ms!).toISOString())); + }; + + append(0); + for (let tick = 1; tick <= measuredLastTick; tick += 1) { + for (let event = 0; event < eventsPerTick; event += 1) append(tick); + } + append(measuredLastTick); + + return { + cadence: derivePlaybackCadence({ + eventCount: events.length, + events, + firstTick: trace.spatial_samples?.[0]?.tick, + lastTick: trace.spatial_samples?.at(-1)?.tick, + speed: 1, + tickDurationMs: trace.tick_duration_ms, + }), + expectedTicks, + membrane, + timeline: timelineFor("measured-record", events, membrane), + }; +}; + +const extractInteriorMap = (markup: string): string => { + const start = markup.indexOf('
'); + const end = markup.indexOf('
{ + loadTimeline(timeline); + setCursor(cursor); + return extractInteriorMap(renderToStaticMarkup(createElement(MembraneView, { + membrane, + tick, + timeline, + }))); +}; + +const renderDirectInterior = ( + membrane: RunTimelineMembrane, + tick: number | undefined, +): string => { + assert.ok(membrane.interiorWorld); + const world = buildViewerWorld(membrane.interiorWorld); + const mapProps = { + nodes: world.nodes, + onSelect: () => undefined, + renderSettings: defaultRenderSettings, + roomPaths: world.roomPaths, + rooms: world.roomGeometries, + selectedNode: world.nodes[0]!, + selectedSkin: viewerSkins[0]!, + presenceByAgent: world.presenceByAgent, + spatialSamples: world.spatialSamples, + tickDurationMs: world.tickDurationMs, + }; + const map = tick === undefined + ? createElement(AsciiMap, mapProps) + : createElement(AsciiMap, { ...mapProps, tick }); + return renderToStaticMarkup(createElement("div", { className: "membrane-interior-map" }, map)); +}; + +describe("MembraneView world tick", () => { + beforeEach(() => resetTimelineStoreForTests()); + + it("matches the outer map's one tick accessor across the measured cursor range", () => { + const fixture = buildMeasuredFixture(); + assert.equal(fixture.timeline.events.length, measuredEventCount); + assert.ok(fixture.cadence.simulatedTimeIndex, "fixture clock must be corroborated"); + + const firstTick = tickAtCursor(fixture.timeline, 0, fixture.cadence); + const lastCursor = fixture.timeline.events.length - 1; + const lastTick = tickAtCursor(fixture.timeline, lastCursor, fixture.cadence); + const firstMap = renderMembraneInterior(fixture.timeline, fixture.membrane, firstTick, 0); + const lastMap = renderMembraneInterior(fixture.timeline, fixture.membrane, lastTick, lastCursor); + assert.notEqual(firstMap, lastMap, "fixture must discriminate between distinct world ticks"); + + for (let cursor = 0; cursor <= lastCursor; cursor += 1) { + assert.equal( + tickAtCursor(fixture.timeline, cursor, fixture.cadence), + fixture.expectedTicks[cursor], + `world tick at cursor ${cursor}`, + ); + } + + for (const cursor of [0, 1, 4_500, 9_000, 13_500, 18_000, lastCursor]) { + const tick = tickAtCursor(fixture.timeline, cursor, fixture.cadence); + assert.equal( + renderMembraneInterior(fixture.timeline, fixture.membrane, tick, cursor), + renderDirectInterior(fixture.membrane, tick), + `interior map must equal the outer map render at cursor ${cursor}`, + ); + } + }); + + it("rejects the event cursor where the simulated world tick belongs", () => { + const fixture = buildMeasuredFixture(); + const cursor = 9_000; + const tick = tickAtCursor(fixture.timeline, cursor, fixture.cadence); + assert.equal(tick, 1_000); + assert.equal(cursor / tick, eventsPerTick, "candidate ticks are ninefold apart"); + + const actual = renderMembraneInterior(fixture.timeline, fixture.membrane, tick, cursor); + assert.notEqual(actual, renderDirectInterior(fixture.membrane, cursor)); + }); + + it("renders one timeless frame as the store cursor advances", () => { + const trace = interiorTrace({ + lastPresenceTick: 3, + presenceStep: 1, + tickDurationMs: 75, + withSpatialSpan: false, + }); + const membrane = membraneFor(trace, "membrane:timeless"); + const events = Array.from({ length: 4 }, (_, cursor) => eventAt(cursor, cursor, "")); + const timeline = timelineFor("timeless-record", events, membrane); + const cadence = derivePlaybackCadence({ eventCount: events.length, events, speed: 1 }); + const cursors = [0, 1, 2, 3]; + + const maps = cursors.map((cursor) => { + const tick = tickAtCursor(timeline, cursor, cadence); + assert.equal(tick, undefined, `record must state no time at cursor ${cursor}`); + return renderMembraneInterior(timeline, membrane, tick, cursor); + }); + assert.ok(maps.every((markup) => markup === maps[0]), "store cursor must not move a timeless map"); + + const timelessFrame = renderDirectInterior(membrane, undefined); + assert.equal(maps[0], timelessFrame); + assert.notEqual(timelessFrame, renderDirectInterior(membrane, 0), "timeless frame is not tick 0"); + }); +}); diff --git a/web/src/portals/MembraneView.tsx b/web/src/portals/MembraneView.tsx index b11dd5d..11c102f 100644 --- a/web/src/portals/MembraneView.tsx +++ b/web/src/portals/MembraneView.tsx @@ -18,16 +18,37 @@ import { StorylineRows } from "./StorylineRows.js"; /** * The membrane interior view (`VIEW_DESIGN.md` rule 5, "descend into a * mind"): a mini map + the interior room's chat + a minds rail filtered to - * the membrane's own members, all reading the SAME store cursor as the outer - * map/chat/minds (rule 7 — nothing here keeps a private clock). A "crossings" - * tab keeps the membrane's own flat storyline available (the representative's - * combined interior+exterior storyline — "the events where interior meets - * exterior"), reusing `StorylineRows` rather than a second row renderer. + * the membrane's own members. Chat, minds, and storyline panes read the same + * store cursor as their outer counterparts. The interior map instead renders + * the record's own simulated world tick supplied by the app's one cursor + * mapping owner, because a map tick is world time and the cursor is an event + * index. That is the same tick the outer map renders, so nothing here keeps a + * private clock (rule 7). A "crossings" tab keeps the membrane's own flat + * storyline available (the representative's combined interior+exterior + * storyline — "the events where interior meets exterior"), reusing + * `StorylineRows` rather than a second row renderer. */ type MembraneTab = "interior" | "crossings"; -export function MembraneView({ timeline, membrane }: { timeline: RunTimeline; membrane: RunTimelineMembrane }) { +export function MembraneView({ + membrane, + tick, + timeline, +}: { + membrane: RunTimelineMembrane; + /** + * The record's own simulated world tick "as of" the scrub cursor, from the + * app's single cursor->tick owner (`../chrome/playbackCadence.ts`'s + * `playbackTickAtCursor` via `../viewer/variableModel.ts`'s `tickAtCursor`), + * computed once by `../viewer/RunReplayShell.tsx` and threaded through + * `StorylinePortal`. `undefined` means the record states no time — the + * interior map then renders `AsciiMap`'s timeless frame, exactly as the outer + * map does. NEVER pass the store cursor here: the cursor is an event index. + */ + tick?: number; + timeline: RunTimeline; +}) { const { cursor, selection, highlightedEventIds } = useTimelineStore(); const [tab, setTab] = useState("interior"); @@ -84,6 +105,10 @@ export function MembraneView({ timeline, membrane }: { timeline: RunTimeline; me rooms={interiorWorld.roomGeometries} selectedNode={selectedNode} selectedSkin={skin} + presenceByAgent={interiorWorld.presenceByAgent} + spatialSamples={interiorWorld.spatialSamples} + tick={tick} + tickDurationMs={interiorWorld.tickDurationMs} /> ) : null}
diff --git a/web/src/portals/StorylinePortal.tsx b/web/src/portals/StorylinePortal.tsx index f2de3ba..cc2638d 100644 --- a/web/src/portals/StorylinePortal.tsx +++ b/web/src/portals/StorylinePortal.tsx @@ -83,8 +83,8 @@ const PORTAL_STACK_WIDTH = 336; * when `elementRef` names a membrane (`../store/timeline.ts`'s * `membraneForRef` — a real `RunTimeline.membranes` entry, e.g. `team:luna`), * this renders `MembraneView` instead of the flat strip — the mini interior - * map + interior chat + minds rail, all reading the same store cursor. This - * is a DATA-PRESENCE branch, not a per-run special case: a leaf agent/room + * map renders the run's world tick while interior chat + minds read the store + * cursor. This is a DATA-PRESENCE branch, not a per-run special case: a leaf agent/room * (no membrane, e.g. every office-sim element) falls through to the exact * flat rendering that existed before membranes did, via the shared * `StorylineRows` component. An agent that happens to BE some membrane's own @@ -113,7 +113,7 @@ export function StorylinePortal({ stackIndex?: number; /** Increment 4: `/api/run-meta`'s `variableSamples`, passed down only so a `variable:` portal can render its trajectory panel — `undefined`/absent for every other element kind's portal. */ variableSamples?: readonly RunMetaVariableSample[]; - /** The world clock's own tick "as of" the scrub cursor (`variableModel.ts`'s `tickAtCursor`, computed once by `RunReplayShell`). */ + /** The run's one world tick "as of" the scrub cursor (`variableModel.ts`'s `tickAtCursor`, computed once by `RunReplayShell`), consumed by both a `variable:` trajectory panel and the membrane interior map. */ variableTick?: number; }) { const { timeline, cursor, highlightedEventIds, openPortals } = useTimelineStore(); @@ -129,7 +129,7 @@ export function StorylinePortal({ return (