diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index cb0af01..c24f0eb 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -15,7 +15,7 @@ jobs:
strategy:
matrix:
- node-version: [18.x, 20.x, 22.x]
+ node-version: [22.x, 24.x]
steps:
- name: Checkout repository
@@ -56,7 +56,7 @@ jobs:
echo "RECON self-documentation completed successfully"
portability:
- name: OS portability / ${{ matrix.os }} / Node 20
+ name: OS portability / ${{ matrix.os }} / Node 24
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
@@ -72,7 +72,7 @@ jobs:
- name: Setup Node.js
uses: actions/setup-node@v4
with:
- node-version: 20.x
+ node-version: 24.x
cache: npm
cache-dependency-path: package-lock.json
@@ -99,3 +99,21 @@ jobs:
- name: Verify public-source release policy
run: npm run release:check
+ required:
+ name: required
+ if: ${{ always() }}
+ needs:
+ - test
+ - portability
+ runs-on: ubuntu-latest
+ steps:
+ - name: Verify mandatory test jobs succeeded
+ env:
+ TEST_RESULT: ${{ needs.test.result }}
+ PORTABILITY_RESULT: ${{ needs.portability.result }}
+ run: |
+ if [ "$TEST_RESULT" != "success" ] || [ "$PORTABILITY_RESULT" != "success" ]; then
+ echo "Required test gate failed: test=$TEST_RESULT portability=$PORTABILITY_RESULT"
+ exit 1
+ fi
+
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7eaf3eb..ff65470 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -15,7 +15,7 @@ linking are the supported ways to experiment with it today.
Prerequisites:
-- Node.js 18 or later;
+- Node.js 22 or later;
- npm;
- Python 3 for Python extractor development.
diff --git a/README.md b/README.md
index 04e409d..df5e5e5 100644
--- a/README.md
+++ b/README.md
@@ -10,7 +10,7 @@ repository exploration, task-scoped context, workspace queries, and evidence
production without replacing canonical architecture or governance authority.
[](LICENSE)
-[](https://nodejs.org/)
+[](https://nodejs.org/)
## Runtime boundary and AI orientation
@@ -26,7 +26,7 @@ human-in-the-loop; the runtime is not an autonomous engineering agent.
## Quick Start
-The supported path is a source checkout. Requires Node.js 18 or later and npm.
+The supported path is a source checkout. Requires Node.js 22 or later and npm.
Python 3 is also required by some extractors.
```bash
diff --git a/adrs/logical/ADR-L-0001-recon-provisional-execution-for-project-level-sema.yaml b/adrs/logical/ADR-L-0001-recon-provisional-execution-for-project-level-sema.yaml
index c4c126a..17b34cc 100644
--- a/adrs/logical/ADR-L-0001-recon-provisional-execution-for-project-level-sema.yaml
+++ b/adrs/logical/ADR-L-0001-recon-provisional-execution-for-project-level-sema.yaml
@@ -47,13 +47,16 @@ interaction_contracts: []
constraints: []
invariants:
- id: 019ff84e-4ece-7387-b33f-3d203e3c968c
- statement: 'Single repository only: RECON discovers files within the current repository.
- Cross-repository reconciliation is out of scope.'
+ statement: 'RECON extraction is repository-local: each repository observation discovers
+ files within its registered repository source. Workspace orchestration may compose
+ multiple repository observations into one derived workspace projection. Cross-workspace
+ reconciliation remains out of scope unless explicitly federated.'
scope: global
enforcement_level: must
enforcement_mechanism: design
verification_method: manual
- rationale: Extracted from ADR-L-0001 specification
+ rationale: Repository source remains the provenance boundary while workspace orchestration
+ provides the bounded multi-repository graph shell.
compliance_frameworks: []
exceptions: []
alias_id: INV-0001
diff --git a/adrs/logical/ADR-L-0009-unified-workspace-scope-model.yaml b/adrs/logical/ADR-L-0009-unified-workspace-scope-model.yaml
index e5084e6..5eac08a 100644
--- a/adrs/logical/ADR-L-0009-unified-workspace-scope-model.yaml
+++ b/adrs/logical/ADR-L-0009-unified-workspace-scope-model.yaml
@@ -20,7 +20,10 @@ context: 'Reconnaissance tools traditionally assume a single repository as the u
level so that cross-repo relationships, shared configuration, and
- aggregate evidence can be captured correctly.
+ aggregate evidence can be captured correctly. A workspace is a durably identified
+ engineering scope composed of one or more repositories. Repositories are the only
+ workspace membership units; graph entities remain derived projections with repository
+ provenance.
'
capabilities:
@@ -68,7 +71,13 @@ decision: 'Scope is a workspace. A workspace contains one or more repositories.
is --workspace
where the workspace contains one repo. No
- special-case code exists for single-repo mode.
+ special-case code exists for single-repo mode. Workspace identity is an immutable
+ UUIDv7 minted by explicit registration creation. Registration-scoped repository
+ UUIDv7 identities preserve provenance across local materialization path changes.
+ Definition revisions are runtime-generated canonical digests and do not change
+ workspace identity. Repository boundaries preserve source provenance; the workspace
+ boundary governs graph traversal. Cross-workspace traversal is fail-closed unless
+ future federation is explicitly declared.
'
consequences:
diff --git a/adrs/logical/ADR-L-0017-recon-workspace-execution-contract.yaml b/adrs/logical/ADR-L-0017-recon-workspace-execution-contract.yaml
index 74d313d..eaae3f8 100644
--- a/adrs/logical/ADR-L-0017-recon-workspace-execution-contract.yaml
+++ b/adrs/logical/ADR-L-0017-recon-workspace-execution-contract.yaml
@@ -31,7 +31,10 @@ context: 'ADR-L-0009 fixes workspace as the universal scope unit. This ADR recor
phase-level extraction behavior; workspace orchestration layered here only
- coordinates repos and aggregates outcomes.
+ coordinates repos and aggregates outcomes. The public runtime adapter treats each
+ refresh as an immutable observation event: observed repositories alone contribute
+ current graph content, partial observations contain no stale failed-repository
+ projection, and zero observed repositories produce a typed refresh failure.
'
implements_physical_system_ref:
diff --git a/adrs/logical/ADR-L-0018-deterministic-workspace-graph-queries.yaml b/adrs/logical/ADR-L-0018-deterministic-workspace-graph-queries.yaml
index 908f76f..cc1cebf 100644
--- a/adrs/logical/ADR-L-0018-deterministic-workspace-graph-queries.yaml
+++ b/adrs/logical/ADR-L-0018-deterministic-workspace-graph-queries.yaml
@@ -59,7 +59,12 @@ context: 'The workspace semantic graph (verb-typed edges in slices/*.yaml, produ
API. loadAidocGraph() only consumes per-repo RECON YAML with _slice blocks. A
- new loader and query layer was required.
+ new loader and query layer was required. Public graph references are scoped by
+ workspace and immutable snapshot identity; legacy node IDs are opaque projection
+ keys rather than durable source/entity identity. Repository identity is carried as
+ separate node and relationship provenance. Traversal is bounded by one workspace
+ and one snapshot projection and rejects foreign workspace or snapshot endpoints.
+ The runtime performs no semantic or probabilistic reasoning.
'
capabilities:
diff --git a/documentation/guides/workspace-initialization.md b/documentation/guides/workspace-initialization.md
index 5359a1c..5a9d965 100644
--- a/documentation/guides/workspace-initialization.md
+++ b/documentation/guides/workspace-initialization.md
@@ -12,7 +12,7 @@ For single-repo usage, see [RECON-README.md](../../instructions/RECON-README.md)
## Prerequisites
-- Node.js 18+
+- Node.js 22+
- npm
- ste-runtime cloned and built:
diff --git a/package-lock.json b/package-lock.json
index 62c9ae2..8d127c6 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,7 +29,7 @@
"@eslint/js": "^9.22.0",
"@mermaid-js/mermaid-cli": "^11.4.0",
"@types/js-yaml": "^4.0.9",
- "@types/node": "^20.0.0",
+ "@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.0",
"ajv": "^8.18.0",
"eslint": "^9.22.0",
@@ -38,7 +38,7 @@
"vitest": "^3.2.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=22.0.0"
}
},
"node_modules/@alloc/quick-lru": {
@@ -2126,9 +2126,9 @@
"license": "MIT"
},
"node_modules/@types/node": {
- "version": "20.19.30",
- "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz",
- "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==",
+ "version": "22.20.1",
+ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz",
+ "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"dev": true,
"license": "MIT",
"dependencies": {
diff --git a/package.json b/package.json
index acc398a..0180333 100644
--- a/package.json
+++ b/package.json
@@ -6,6 +6,13 @@
"type": "module",
"main": "dist/index.js",
"types": "dist/index.d.ts",
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js",
+ "default": "./dist/index.js"
+ }
+ },
"files": [
"dist",
"README.md",
@@ -27,6 +34,7 @@
"test:watch": "vitest",
"test:unit": "vitest run",
"test:integration": "node scripts/prove-architecture-compile.mjs",
+ "test:public-tarball": "node scripts/prove-public-tarball.mjs",
"release:check": "node scripts/check-release-policy.mjs",
"test:coverage": "vitest run --coverage",
"lint": "eslint src vitest.config.ts",
@@ -61,7 +69,7 @@
"@eslint/js": "^9.22.0",
"@mermaid-js/mermaid-cli": "^11.4.0",
"@types/js-yaml": "^4.0.9",
- "@types/node": "^20.0.0",
+ "@types/node": "^22.0.0",
"@vitest/coverage-v8": "^3.2.0",
"ajv": "^8.18.0",
"eslint": "^9.22.0",
@@ -70,7 +78,7 @@
"vitest": "^3.2.0"
},
"engines": {
- "node": ">=18.0.0"
+ "node": ">=22.0.0"
},
"packageManager": "npm@10.9.4",
"keywords": [
diff --git a/scripts/init.cjs b/scripts/init.cjs
index d4e1f5a..bb24af1 100644
--- a/scripts/init.cjs
+++ b/scripts/init.cjs
@@ -98,7 +98,7 @@ ${c.cyan}EXAMPLES:${c.reset}
node scripts/init.cjs --mcp
${c.cyan}WHAT THIS DOES:${c.reset}
- 1. Validates prerequisites (Node.js 18+, npm)
+ 1. Validates prerequisites (Node.js 22+, npm)
2. Installs dependencies (npm install)
3. Builds the project (npm run build)
4. Runs initial RECON to create semantic graph
@@ -405,7 +405,7 @@ function printSummary(success, options) {
log(`${c.red}${c.bold}Bootstrap failed. See errors above.${c.reset}`);
log('');
log('Common fixes:');
- log(' 1. Ensure Node.js 18+ is installed');
+ log(' 1. Ensure Node.js 22+ is installed');
log(' 2. Run from the ste-runtime-private directory');
log(' 3. Check network connectivity for npm install');
log(' 4. Try: rm -rf node_modules && npm install');
diff --git a/scripts/prove-public-tarball.mjs b/scripts/prove-public-tarball.mjs
new file mode 100644
index 0000000..07ada56
--- /dev/null
+++ b/scripts/prove-public-tarball.mjs
@@ -0,0 +1,94 @@
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+import { execFile } from 'node:child_process';
+import { promisify } from 'node:util';
+
+const execFileAsync = promisify(execFile);
+const repositoryRoot = path.resolve(fileURLToPath(new URL('../', import.meta.url)));
+const fixtureRoot = path.join(repositoryRoot, 'test', 'fixtures', 'public-consumer');
+const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ste-runtime-public-tarball-'));
+const consumerRoot = path.join(tempRoot, 'consumer');
+const packRoot = path.join(tempRoot, 'pack');
+const sourceFixtureRoot = path.join(consumerRoot, 'source-fixture');
+const packNpmCache = path.join(tempRoot, 'npm-pack-cache');
+const installNpmCache = process.env.npm_config_cache
+ ?? (process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'npm-cache') : undefined);
+const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm';
+const tscCommand = process.platform === 'win32'
+ ? path.join(consumerRoot, 'node_modules', '.bin', 'tsc.cmd')
+ : path.join(consumerRoot, 'node_modules', '.bin', 'tsc');
+
+try {
+ await fs.mkdir(consumerRoot, { recursive: true });
+ await fs.mkdir(packRoot, { recursive: true });
+ await fs.copyFile(path.join(fixtureRoot, 'package.json'), path.join(consumerRoot, 'package.json'));
+ await fs.copyFile(path.join(fixtureRoot, 'consumer.ts'), path.join(consumerRoot, 'consumer.ts'));
+ await fs.copyFile(path.join(fixtureRoot, 'bootstrap.mjs'), path.join(consumerRoot, 'bootstrap.mjs'));
+ await fs.copyFile(path.join(fixtureRoot, 'tsconfig.json'), path.join(consumerRoot, 'tsconfig.json'));
+ await fs.mkdir(path.join(sourceFixtureRoot, 'src'), { recursive: true });
+ await fs.writeFile(path.join(sourceFixtureRoot, 'package.json'), '{"name":"public-consumer-source-fixture","version":"1.0.0"}\n');
+ await fs.writeFile(path.join(sourceFixtureRoot, 'src', 'index.ts'), 'export const fixture = 1;\n');
+
+ await execFileAsync(npmCommand, ['run', 'build'], {
+ cwd: repositoryRoot,
+ maxBuffer: 4 * 1024 * 1024,
+ shell: true,
+ env: { ...process.env, npm_config_cache: packNpmCache },
+ });
+
+ const { stdout } = await execFileAsync(npmCommand, ['pack', '--ignore-scripts', '--json', '--pack-destination', packRoot], {
+ cwd: repositoryRoot,
+ maxBuffer: 1024 * 1024,
+ shell: true,
+ env: {
+ ...process.env,
+ npm_config_cache: packNpmCache,
+ },
+ });
+ const packed = JSON.parse(stdout.slice(stdout.indexOf('[')));
+ const tarball = path.resolve(packRoot, packed[0].filename);
+
+ await execFileAsync(npmCommand, [
+ 'install', tarball, '--ignore-scripts', '--no-audit', '--no-fund', '--no-package-lock',
+ ], {
+ cwd: consumerRoot,
+ maxBuffer: 4 * 1024 * 1024,
+ shell: true,
+ env: {
+ ...process.env,
+ ...(installNpmCache ? { npm_config_cache: installNpmCache } : {}),
+ npm_config_fetch_retries: '0',
+ npm_config_fetch_timeout: '10000',
+ },
+ });
+
+ await execFileAsync(tscCommand, ['--project', 'tsconfig.json'], {
+ cwd: consumerRoot,
+ maxBuffer: 4 * 1024 * 1024,
+ shell: true,
+ });
+
+ const result = await execFileAsync(process.execPath, ['bootstrap.mjs', sourceFixtureRoot], {
+ cwd: consumerRoot,
+ maxBuffer: 1024 * 1024,
+ });
+ for (const forbidden of ['.ste', '.ste-self', '.workspace-graph']) {
+ const candidate = path.join(sourceFixtureRoot, forbidden);
+ await expectMissing(candidate);
+ }
+ process.stdout.write(result.stdout);
+} finally {
+ await fs.rm(tempRoot, { recursive: true, force: true });
+}
+
+async function expectMissing(candidate) {
+ try {
+ await fs.access(candidate);
+ throw new Error(`Packed consumer created forbidden state path: ${candidate}`);
+ } catch (error) {
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return;
+ throw error;
+ }
+}
diff --git a/src/index.ts b/src/index.ts
index 672d4f0..07ee9bc 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -1,206 +1,8 @@
/**
- * ste-runtime
+ * Supported ste-runtime Public Runtime SDK.
*
- * Portable RECON, RSS, workspace-graph, and runtime-evidence implementation
- * for supervised AI-assisted development workflows.
- *
- * ## For AI Coding Assistants (Cursor, Copilot, etc.)
- *
- * A built source checkout provides programmatic access to the semantic graph.
- * Instead of using the CLI, local consumers can import and call the functions
- * directly:
- *
- * ```typescript
- * import { initRssContext, search, blastRadius } from './dist/index.js';
- *
- * const ctx = await initRssContext('.ste/state');
- * const results = search(ctx, 'user authentication');
- * const impact = blastRadius(ctx, results.nodes[0].key);
- * ```
- *
- * See instructions/RSS-PROGRAMMATIC-API.md for current documentation. These
- * exports are documented for source-checkout use and do not create an npm
- * compatibility commitment while the package remains private and unpublished.
- *
- * @module ste-runtime
+ * Legacy RECON, RSS, CLI, MCP, and workspace implementation modules remain
+ * available through their repository-internal paths. The package root exposes
+ * only the narrow production foundation contract.
*/
-
-// ============================================================================
-// RSS - Runtime State Slicing (semantic graph traversal)
-// ============================================================================
-
-export {
- // Core initialization
- initRssContext,
-
- // Direct retrieval
- lookup,
- lookupByKey,
-
- // Graph traversal
- dependencies,
- dependents,
- blastRadius,
-
- // Discovery
- search,
- byTag,
- findEntryPoints,
-
- // Context assembly
- assembleContext,
-
- // Statistics
- getGraphStats,
-
- // Hybrid workflow helpers (RSS + Grep)
- extractFilePaths,
- getRelevantFiles,
-
- // Graph validation and health
- validateBidirectionalEdges,
- findOrphanedNodes,
- findAllBrokenEdges,
- validateGraphHealth,
-
- // Types
- type RssContext,
- type RssQueryResult,
- type BrokenEdge,
- type BidirectionalInconsistency,
-} from './rss/rss-operations.js';
-
-export {
- // Graph data types
- type AidocNode,
- type AidocGraph,
- type AidocEdge,
- loadAidocGraph,
-} from './rss/graph-loader.js';
-
-// ============================================================================
-// RECON - Semantic Extraction (optional - typically run via CLI)
-// ============================================================================
-
-// Note: RECON is primarily invoked via CLI (npm run recon:full)
-// but the engine can be imported for programmatic use if needed.
-
-export { executeRecon, type ReconOptions, type ReconResult } from './recon/index.js';
-
-// ============================================================================
-// CQI - Conversational Query Interface
-// ============================================================================
-
-export {
- // Engine for session-based queries (caches context)
- ConversationalQueryEngine,
-
- // Convenience function for one-off queries
- ask,
-
- // Output formatters
- formatForHuman,
- formatForAgent,
-
- // Types
- type ConversationalResponse,
- type QueryIntent,
- type NodeSummary,
-} from './rss/conversational-query.js';
-
-// ============================================================================
-// Architecture Bundle Discovery
-// ============================================================================
-
-export {
- loadArchitectureBundle,
- type ArchitectureBundleArtifact,
- type ArchitectureBundleIndexSummary,
- type ArchitectureBundleManifestSummary,
- type ArchitectureBundleResult,
- type ArchitectureBundleStatus,
-} from './discovery/architecture-bundle.js';
-
-export {
- buildArchitectureEvidence,
- runArchitectureEvidenceCommand,
- deriveSubjectsFromBundle,
- type ArchitectureEvidence,
- type ArchitectureEvidenceFreshnessStatus,
- type ArchitectureEvidenceVersion,
- type EvidenceSubject,
- type EvidenceSubjectKind,
- type EvidenceSubjectEffect,
-} from './cli/evidence-command.js';
-
-// ============================================================================
-// Workspace Graph Queries (non-LLM canned traversals)
-// ============================================================================
-
-export {
- loadWorkspaceGraph,
- systemDependencies,
- componentIntegration,
- blastRadiusWorkspace,
- toMermaid,
- toTable,
- toAdjacencyMatrix,
- type WorkspaceGraph,
- type WorkspaceNode,
- type WorkspaceEdge,
- type SystemDependencyResult,
- type RepoDependency,
- type ComponentIntegrationResult,
- type IntegrationGroup,
- type WorkspaceBlastRadiusResult,
- type BlastTier,
- type CannedQueryResult,
- type AdjacencyMatrixResult,
-} from './workspace/index.js';
-
-export {
- assertMvcDefinitionContract,
- assertMvcFederatedIdentity,
- assertMvcSnapshotCandidateOnly,
- buildMvcSnapshotCandidate,
- canonicalMvcFingerprintInput,
- recommendMvcDepthFromTopology,
- traverseMvcSFromLinkageSurface,
- traverseMvcSCandidates,
- type BuildMvcSnapshotInput,
- type MvcLinkageSurface,
- type MvcLinkageSurfaceRelationshipRecord,
- type MvcDepthRecommendation,
- type MvcDepthRecommendationInput,
- type MvcDefinition,
- type MvcNegativeSpace,
- type MvcRationale,
- type MvcRef,
- type MvcRefWithHash,
- type MvcSnapshot,
- type MvcTopologyMetrics,
- type MvcTraversalRelationshipRecord,
- type TraverseMvcSFromLinkageSurfaceInput,
- type TraverseMvcSCandidatesInput,
-} from './workspace/mvc-evolution.js';
-
-// ============================================================================
-// Architecture compilation and runtime-owned evidence artifacts
-// ============================================================================
-
-export {
- compileArchitecture,
- runArchitecturePipeline,
- architectureMerge,
- emptyReconSnapshot,
- buildAdrGraph,
- assembleDiscoveryBundle,
- type CompileArchitectureOptions,
- type CompileArchitectureResult,
- type PipelineRunOptions,
- type ArchModelState,
- type AdrGraph,
- type ReconArchitectureSnapshot,
- type CompileDiagnostic,
- type DiscoveryBundle,
-} from './architecture/index.js';
+export * from './public/index.js';
diff --git a/src/public/canonical.ts b/src/public/canonical.ts
new file mode 100644
index 0000000..4276910
--- /dev/null
+++ b/src/public/canonical.ts
@@ -0,0 +1,75 @@
+import crypto from 'node:crypto';
+import path from 'node:path';
+
+import type {
+ DefinitionRevision,
+ RepositoryDefinition,
+ RepositoryId,
+ WorkspaceDefinition,
+} from './types.js';
+
+export const PUBLIC_RUNTIME_CONTRACT_VERSION = '1.0.0';
+
+export function normalizeLocalSourcePath(input: string): string {
+ const resolved = path.resolve(input.trim());
+ const normalized = path.normalize(resolved);
+ return process.platform === 'win32' ? normalized.replace(/\\/g, '/').toLowerCase() : normalized;
+}
+
+export function canonicalize(value: unknown): string {
+ if (value === undefined) return 'null';
+ if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
+ return JSON.stringify(value);
+ }
+ if (Array.isArray(value)) {
+ return `[${value.map(canonicalize).join(',')}]`;
+ }
+ if (typeof value === 'object') {
+ const record = value as Record;
+ return `{${Object.keys(record)
+ .sort()
+ .filter(key => record[key] !== undefined)
+ .map(key => `${JSON.stringify(key)}:${canonicalize(record[key])}`)
+ .join(',')}}`;
+ }
+ throw new TypeError(`Unsupported value in canonical representation: ${typeof value}`);
+}
+
+export function canonicalDefinition(definition: WorkspaceDefinition): string {
+ const repositories = [...definition.repositories]
+ .map(repository => ({
+ repositoryId: repository.repositoryId,
+ source: {
+ kind: repository.source.kind,
+ path: normalizeLocalSourcePath(repository.source.path),
+ },
+ }))
+ .sort((a, b) => a.repositoryId.localeCompare(b.repositoryId));
+
+ return canonicalize({
+ contractVersion: PUBLIC_RUNTIME_CONTRACT_VERSION,
+ repositories,
+ });
+}
+
+export function definitionRevision(definition: WorkspaceDefinition): DefinitionRevision {
+ return `sha256:${crypto.createHash('sha256').update(canonicalDefinition(definition), 'utf8').digest('hex')}` as DefinitionRevision;
+}
+
+export function canonicalObservation(value: unknown): string {
+ return canonicalize(value);
+}
+
+export function observationFingerprint(value: unknown): string {
+ return `sha256:${crypto.createHash('sha256').update(canonicalObservation(value), 'utf8').digest('hex')}`;
+}
+
+export function canonicalRepositoryDefinition(
+ repositoryId: RepositoryId,
+ sourcePath: string,
+): RepositoryDefinition {
+ return {
+ repositoryId,
+ source: { kind: 'local', path: normalizeLocalSourcePath(sourcePath) },
+ };
+}
diff --git a/src/public/errors.ts b/src/public/errors.ts
new file mode 100644
index 0000000..f4f3758
--- /dev/null
+++ b/src/public/errors.ts
@@ -0,0 +1,25 @@
+import type { RepositoryId, RuntimeDiagnostic } from './types.js';
+
+export class RuntimeContractError extends Error {
+ readonly code: string;
+ readonly diagnostic: RuntimeDiagnostic;
+
+ constructor(code: string, message: string, repositoryIds?: readonly RepositoryId[]) {
+ super(`${code}: ${message}`);
+ this.name = 'RuntimeContractError';
+ this.code = code;
+ this.diagnostic = { code, message, repositoryIds };
+ }
+}
+
+export class RefreshError extends Error {
+ readonly code: string;
+ readonly diagnostics: readonly RuntimeDiagnostic[];
+
+ constructor(code: string, message: string, diagnostics: readonly RuntimeDiagnostic[] = []) {
+ super(message);
+ this.name = 'RefreshError';
+ this.code = code;
+ this.diagnostics = diagnostics;
+ }
+}
diff --git a/src/public/graph.ts b/src/public/graph.ts
new file mode 100644
index 0000000..3e22884
--- /dev/null
+++ b/src/public/graph.ts
@@ -0,0 +1,84 @@
+import { RuntimeContractError } from './errors.js';
+import type {
+ GraphNodeRef,
+ GraphNode,
+ GraphProjection,
+ GraphRelationship,
+ SnapshotId,
+ TraversalOptions,
+ WorkspaceId,
+} from './types.js';
+
+function refKey(ref: GraphNodeRef): string {
+ return `${ref.workspaceId}:${ref.snapshotId}:${ref.nodeId}`;
+}
+
+export function createGraphProjection(
+ workspaceId: WorkspaceId,
+ snapshotId: SnapshotId,
+ nodes: readonly GraphNode[],
+ relationships: readonly GraphRelationship[],
+): GraphProjection {
+ const nodeMap = new Map(nodes.map(node => [refKey(node.ref), node]));
+ const outgoing = new Map();
+ for (const relationship of relationships) {
+ const key = refKey(relationship.source);
+ const existing = outgoing.get(key) ?? [];
+ existing.push(relationship);
+ outgoing.set(key, existing);
+ }
+
+ const getNode = (ref: GraphNodeRef): GraphNode | undefined => {
+ if (ref.workspaceId !== workspaceId || ref.snapshotId !== snapshotId) {
+ throw new RuntimeContractError(
+ 'FOREIGN_GRAPH_PROJECTION',
+ `Node ${ref.nodeId} does not belong to graph projection ${workspaceId}/${snapshotId}`,
+ );
+ }
+ return nodeMap.get(refKey(ref));
+ };
+
+ const traverse = (start: GraphNodeRef, options: TraversalOptions = {}): readonly GraphNodeRef[] => {
+ if (start.workspaceId !== workspaceId || start.snapshotId !== snapshotId) {
+ throw new RuntimeContractError(
+ start.workspaceId !== workspaceId ? 'FOREIGN_WORKSPACE_TRAVERSAL' : 'FOREIGN_GRAPH_PROJECTION',
+ `Node ${start.nodeId} does not belong to graph projection ${workspaceId}/${snapshotId}`,
+ );
+ }
+ if (!nodeMap.has(refKey(start))) return [];
+
+ const maxDepth = options.maxDepth ?? Number.POSITIVE_INFINITY;
+ const maxNodes = options.maxNodes ?? Number.POSITIVE_INFINITY;
+ const result: GraphNodeRef[] = [];
+ const seen = new Set();
+ const queue: Array<{ ref: GraphNodeRef; depth: number }> = [{ ref: start, depth: 0 }];
+
+ while (queue.length > 0 && result.length < maxNodes) {
+ const current = queue.shift()!;
+ const key = refKey(current.ref);
+ if (seen.has(key)) continue;
+ seen.add(key);
+ result.push(current.ref);
+ if (current.depth >= maxDepth) continue;
+
+ for (const relationship of outgoing.get(key) ?? []) {
+ if (relationship.target.workspaceId !== workspaceId || relationship.target.snapshotId !== snapshotId) {
+ throw new RuntimeContractError(
+ relationship.target.workspaceId !== workspaceId ? 'FOREIGN_WORKSPACE_TRAVERSAL' : 'FOREIGN_GRAPH_PROJECTION',
+ `Relationship target ${relationship.target.nodeId} crosses the graph projection boundary`,
+ );
+ }
+ queue.push({ ref: relationship.target, depth: current.depth + 1 });
+ }
+ }
+
+ return result;
+ };
+
+ return Object.freeze({
+ nodes: Object.freeze([...nodes]),
+ relationships: Object.freeze([...relationships]),
+ getNode,
+ traverse,
+ });
+}
diff --git a/src/public/index.ts b/src/public/index.ts
new file mode 100644
index 0000000..0db6ccb
--- /dev/null
+++ b/src/public/index.ts
@@ -0,0 +1,34 @@
+export { createRuntime } from './runtime.js';
+export { RuntimeContractError, RefreshError } from './errors.js';
+export type {
+ CreateRegistrationInput,
+ CreateRepositoryInput,
+ DefinitionRevision,
+ EntityProvenance,
+ GraphNodeRef,
+ GraphNode,
+ GraphProjection,
+ GraphRelationship,
+ LocalRepositorySource,
+ RegisteredRepositoryMetadata,
+ RepositoryDefinition,
+ RepositoryDisplayMetadata,
+ RepositoryId,
+ RepositoryObservation,
+ RepositoryObservationStatus,
+ RetainedRepositoryInput,
+ ReviseRegistrationInput,
+ RelationshipProvenance,
+ Runtime,
+ RuntimeCapabilityManifest,
+ RuntimeDiagnostic,
+ RuntimeSnapshot,
+ SnapshotId,
+ SourceProvenance,
+ TraversalOptions,
+ WorkspaceDefinition,
+ WorkspaceDisplayMetadata,
+ WorkspaceHandle,
+ WorkspaceId,
+ WorkspaceRegistration,
+} from './types.js';
diff --git a/src/public/runtime.test.ts b/src/public/runtime.test.ts
new file mode 100644
index 0000000..aeb6fe6
--- /dev/null
+++ b/src/public/runtime.test.ts
@@ -0,0 +1,333 @@
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+
+import { describe, expect, it } from 'vitest';
+
+import { mergeWorkspaceGraph, preflightWorkspaceGraphIdentity, WorkspaceIdentityCollisionError } from '../workspace/workspace-merge.js';
+import { canonicalize, definitionRevision } from './canonical.js';
+import { RefreshError, RuntimeContractError } from './errors.js';
+import { createGraphProjection } from './graph.js';
+import { createRuntime } from './runtime.js';
+import type { GraphNodeRef, SnapshotId, WorkspaceId } from './types.js';
+
+const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+async function createFixtureRepository(prefix: string): Promise {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), `${prefix}-`));
+ await fs.mkdir(path.join(root, 'src'), { recursive: true });
+ await fs.writeFile(path.join(root, 'package.json'), '{"name":"public-runtime-fixture","version":"1.0.0"}\n');
+ await fs.writeFile(path.join(root, 'src', 'index.ts'), 'export const fixture = 1;\n');
+ return root;
+}
+
+describe('public runtime registration contract', () => {
+ it('canonicalizes definition property and repository ordering while excluding undefined values', () => {
+ const repositoryA = '019ffc3c-0000-7000-8000-000000000001' as never;
+ const repositoryB = '019ffc3c-0000-7000-8000-000000000002' as never;
+ const first = {
+ repositories: [
+ { repositoryId: repositoryB, source: { kind: 'local' as const, path: process.cwd() } },
+ { repositoryId: repositoryA, source: { kind: 'local' as const, path: process.cwd() } },
+ ],
+ };
+ const second = {
+ repositories: [
+ { source: { path: process.cwd(), kind: 'local' as const }, repositoryId: repositoryA },
+ { source: { path: process.cwd(), kind: 'local' as const }, repositoryId: repositoryB },
+ ],
+ };
+
+ expect(definitionRevision(first)).toBe(definitionRevision(second));
+ expect(canonicalize({ alias: 'ignored', value: undefined })).toBe('{"alias":"ignored"}');
+ });
+
+ it('mints UUIDv7 workspace and repository identities only during registration creation', async () => {
+ const runtime = createRuntime();
+ const registration = await runtime.createRegistration({
+ repositories: [
+ { source: { kind: 'local', path: process.cwd() }, display: { alias: 'runtime' } },
+ ],
+ });
+
+ expect(registration.workspaceId).toMatch(UUID_V7);
+ expect(registration.definition.repositories[0]?.repositoryId).toMatch(UUID_V7);
+ expect(registration.repositories[0]?.repositoryId).toBe(registration.definition.repositories[0]?.repositoryId);
+ await runtime.close();
+ });
+
+ it('preserves workspace and retained repository identities across explicit revision', async () => {
+ const runtime = createRuntime();
+ const registration = await runtime.createRegistration({
+ repositories: [{ source: { kind: 'local', path: process.cwd() }, display: { alias: 'before' } }],
+ });
+ const repositoryId = registration.definition.repositories[0]!.repositoryId;
+ const revised = await runtime.reviseRegistration(registration, {
+ retain: [{
+ repositoryId,
+ source: { kind: 'local', path: process.cwd() },
+ display: { alias: 'after' },
+ }],
+ add: [],
+ remove: [],
+ });
+
+ expect(revised.workspaceId).toBe(registration.workspaceId);
+ expect(revised.definition.repositories[0]?.repositoryId).toBe(repositoryId);
+ expect(revised.definitionRevision).toBe(registration.definitionRevision);
+ expect(revised.repositories[0]?.display?.alias).toBe('after');
+ await runtime.close();
+ });
+
+ it('rejects a registration whose caller-supplied definition revision is fabricated', async () => {
+ const runtime = createRuntime();
+ const registration = await runtime.createRegistration({
+ repositories: [{ source: { kind: 'local', path: process.cwd() } }],
+ });
+
+ await expect(runtime.open({ ...registration, definitionRevision: 'sha256:fabricated' as never }))
+ .rejects.toMatchObject({ code: 'DEFINITION_REVISION_MISMATCH' });
+ await runtime.close();
+ });
+
+ it('rejects empty workspaces and does not persist discarded registrations', async () => {
+ const runtime = createRuntime();
+ await expect(runtime.createRegistration({ repositories: [] })).rejects.toMatchObject({ code: 'EMPTY_WORKSPACE' });
+ const registration = await runtime.createRegistration({
+ repositories: [{ source: { kind: 'local', path: process.cwd() } }],
+ });
+ expect(registration).toBeDefined();
+ await runtime.close();
+ });
+});
+
+describe('snapshot-bound graph projection', () => {
+ it('traverses a deterministic cross-repository relationship inside one workspace snapshot', () => {
+ const workspaceId = '019ffc3c-0000-7000-8000-000000000000' as WorkspaceId;
+ const snapshotId = '019ffc3c-0000-7002-8000-000000000000' as SnapshotId;
+ const source: GraphNodeRef = { workspaceId, snapshotId, nodeId: 'Service:repo-a' };
+ const target: GraphNodeRef = { workspaceId, snapshotId, nodeId: 'Endpoint:repo-b:get:health' };
+ const graph = createGraphProjection(
+ workspaceId,
+ snapshotId,
+ [
+ { ref: source, type: 'Service', name: 'A', provenance: { snapshotId, sources: [{ repositoryId: 'repo-a' as never }] } },
+ { ref: target, type: 'Endpoint', name: 'B', provenance: { snapshotId, sources: [{ repositoryId: 'repo-b' as never }] } },
+ ],
+ [{
+ source,
+ target,
+ verb: 'calls',
+ provenance: {
+ snapshotId,
+ sources: [{ repositoryId: 'repo-a' as never }, { repositoryId: 'repo-b' as never }],
+ evidence: 'deterministic fixture evidence',
+ },
+ }],
+ );
+
+ expect(graph.traverse(source)).toEqual([source, target]);
+ expect(graph.getNode(source)?.provenance.sources[0]?.repositoryId).toBe('repo-a');
+ expect(graph.getNode(target)?.provenance.sources[0]?.repositoryId).toBe('repo-b');
+ });
+
+ it('treats legacy node keys as opaque snapshot-scoped projection identity', () => {
+ const workspaceId = '019ffc3c-0000-7000-8000-000000000000' as WorkspaceId;
+ const snapshotId = '019ffc3c-0000-7002-8000-000000000000' as SnapshotId;
+ const otherSnapshotId = '019ffc3c-0000-7003-8000-000000000000' as SnapshotId;
+ const ref: GraphNodeRef = { workspaceId, snapshotId, nodeId: 'Service:legacy-execution-key' };
+ const graph = createGraphProjection(
+ workspaceId,
+ snapshotId,
+ [{ ref, type: 'Service', name: 'Example', provenance: { snapshotId, sources: [] } }],
+ [],
+ );
+
+ expect(ref).toEqual({ workspaceId, snapshotId, nodeId: 'Service:legacy-execution-key' });
+ expect(() => graph.getNode({ workspaceId, snapshotId: otherSnapshotId, nodeId: ref.nodeId }))
+ .toThrowError('FOREIGN_GRAPH_PROJECTION');
+ });
+
+ it('fails closed when traversal crosses the workspace boundary', () => {
+ const workspaceId = '019ffc3c-0000-7000-8000-000000000000' as WorkspaceId;
+ const foreign = '019ffc3c-0000-7001-8000-000000000000' as WorkspaceId;
+ const snapshotId = '019ffc3c-0000-7002-8000-000000000000' as SnapshotId;
+ const start: GraphNodeRef = { workspaceId, snapshotId, nodeId: 'a' };
+ const graph = createGraphProjection(
+ workspaceId,
+ snapshotId,
+ [{ ref: start, type: 'Service', name: 'A', provenance: { snapshotId, sources: [] } }],
+ [{
+ source: start,
+ target: { workspaceId: foreign, snapshotId, nodeId: 'b' },
+ verb: 'calls',
+ provenance: { snapshotId, sources: [] },
+ }],
+ );
+
+ expect(() => graph.traverse(start)).toThrowError(RuntimeContractError);
+ expect(() => graph.traverse({ workspaceId: foreign, snapshotId, nodeId: 'b' })).toThrowError('FOREIGN_WORKSPACE_TRAVERSAL');
+ expect(() => graph.traverse({ workspaceId, snapshotId: '019ffc3c-0000-7003-8000-000000000000' as SnapshotId, nodeId: 'a' }))
+ .toThrowError('FOREIGN_GRAPH_PROJECTION');
+ });
+});
+
+describe('pre-merge workspace identity guard', () => {
+ async function createSlices(): Promise {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ste-runtime-collision-'));
+ await fs.mkdir(path.join(root, 'slices'));
+ await fs.writeFile(path.join(root, 'slices', 'repo-a.yaml'), `schema_version: '1.0'\nrepo: repo-a\ngenerated_by: test\ngenerated_at: now\nnodes:\n - id: Service:shared\n type: Service\n name: repo-a\n provenance:\n source_path: src/index.ts\n source_ref: x\n repo: repo-a\nedges: []\n`);
+ await fs.writeFile(path.join(root, 'slices', 'repo-b.yaml'), `schema_version: '1.0'\nrepo: repo-b\ngenerated_by: test\ngenerated_at: now\nnodes:\n - id: Service:shared\n type: Service\n name: repo-b\n provenance:\n source_path: src/index.ts\n source_ref: x\n repo: repo-b\nedges: []\n`);
+ return root;
+ }
+
+ it('fails before legacy first-wins merge and identifies both repositories', async () => {
+ const root = await createSlices();
+ await expect(preflightWorkspaceGraphIdentity(root)).rejects.toSatisfy(error => {
+ expect(error).toBeInstanceOf(WorkspaceIdentityCollisionError);
+ expect((error as WorkspaceIdentityCollisionError).collisions[0]?.repositories).toEqual(['repo-a', 'repo-b']);
+ return true;
+ });
+ await fs.rm(root, { recursive: true, force: true });
+ });
+
+ it('allows unique workspace entity IDs across repositories', async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ste-runtime-unique-'));
+ await fs.mkdir(path.join(root, 'slices'));
+ await fs.writeFile(path.join(root, 'slices', 'repo-a.yaml'), `schema_version: '1.0'\nrepo: repo-a\ngenerated_by: test\ngenerated_at: now\nnodes:\n - id: Service:a\n type: Service\n name: repo-a\n provenance:\n source_path: src/a.ts\n source_ref: a\n repo: repo-a\nedges: []\n`);
+ await fs.writeFile(path.join(root, 'slices', 'repo-b.yaml'), `schema_version: '1.0'\nrepo: repo-b\ngenerated_by: test\ngenerated_at: now\nnodes:\n - id: Service:b\n type: Service\n name: repo-b\n provenance:\n source_path: src/b.ts\n source_ref: b\n repo: repo-b\nedges: []\n`);
+ await expect(preflightWorkspaceGraphIdentity(root)).resolves.toBeUndefined();
+ await fs.rm(root, { recursive: true, force: true });
+ });
+});
+
+describe('cross-repository relationship provenance', () => {
+ it('retains deterministic source and target repository evidence through merge', async () => {
+ const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ste-runtime-edge-provenance-'));
+ await fs.mkdir(path.join(root, 'slices'));
+ const slice = (repo: string, nodeId: string) => `schema_version: '1.0'\nrepo: ${repo}\ngenerated_by: test\ngenerated_at: now\nnodes:\n - id: ${nodeId}\n type: Service\n name: ${repo}\n provenance:\n source_path: src/index.ts\n source_ref: service\n repo: ${repo}\nedges: []\n`;
+ await fs.writeFile(path.join(root, 'slices', 'repo-a.yaml'), slice('repo-a', 'Service:a'));
+ await fs.writeFile(path.join(root, 'slices', 'repo-b.yaml'), slice('repo-b', 'Service:b'));
+ await fs.writeFile(path.join(root, 'workspace-edges.yaml'), `cross_repo_edges:\n - from: Service:a\n to: Service:b\n verb: calls\n confidence: high\n provenance:\n source_repo: repo-a\n target_repo: repo-b\n evidence: bilateral test evidence\n`);
+
+ const result = await mergeWorkspaceGraph(root);
+ expect(result.graph.edges[0]?.provenance).toEqual({
+ source_repo: 'repo-a',
+ target_repo: 'repo-b',
+ evidence: 'bilateral test evidence',
+ });
+ await fs.rm(root, { recursive: true, force: true });
+ });
+});
+
+describe('no-source current projection', () => {
+ it('returns a typed failure for an orphaned materialization and never fabricates a snapshot', async () => {
+ const runtime = createRuntime();
+ const registration = await runtime.createRegistration({
+ repositories: [{ source: { kind: 'local', path: path.join(os.tmpdir(), 'missing-ste-runtime-repository') } }],
+ });
+ const workspace = await runtime.open(registration);
+ await expect(workspace.refresh()).rejects.toSatisfy(error => {
+ expect(error).toBeInstanceOf(RefreshError);
+ expect((error as RefreshError).code).toBe('NO_SOURCE_OBSERVED');
+ expect((error as RefreshError).diagnostics[0]?.code).toBe('REPOSITORY_ORPHANED');
+ return true;
+ });
+ await runtime.close();
+ });
+
+ it('returns a partial current projection without stale content for an orphaned member', async () => {
+ const observedRoot = await createFixtureRepository('ste-runtime-observed');
+ const orphanedRoot = path.join(os.tmpdir(), `missing-ste-runtime-${Date.now()}`);
+ const runtime = createRuntime();
+ try {
+ const registration = await runtime.createRegistration({
+ repositories: [
+ { source: { kind: 'local', path: observedRoot } },
+ { source: { kind: 'local', path: orphanedRoot } },
+ ],
+ });
+ const workspace = await runtime.open(registration);
+ const snapshot = await workspace.refresh();
+ const observedId = snapshot.repositoryObservations.find(observation => observation.status === 'observed')!.repositoryId;
+ const orphanedId = snapshot.repositoryObservations.find(observation => observation.status === 'orphaned')!.repositoryId;
+
+ expect(snapshot.status).toBe('partial');
+ expect(snapshot.repositoryObservations).toEqual(expect.arrayContaining([
+ expect.objectContaining({ repositoryId: observedId, status: 'observed' }),
+ expect.objectContaining({ repositoryId: orphanedId, status: 'orphaned' }),
+ ]));
+ expect(snapshot.graph.nodes.every(node => node.provenance.sources.every(source => source.repositoryId === observedId))).toBe(true);
+ expect(snapshot.graph.relationships.every(edge => edge.provenance.sources.every(source => source.repositoryId === observedId))).toBe(true);
+ } finally {
+ await runtime.close();
+ await fs.rm(observedRoot, { recursive: true, force: true });
+ }
+ });
+
+ it('does not substitute a prior snapshot after a failed refresh', async () => {
+ const sourceRoot = await createFixtureRepository('ste-runtime-failed-refresh');
+ const runtime = createRuntime();
+ try {
+ const registration = await runtime.createRegistration({
+ repositories: [{ source: { kind: 'local', path: sourceRoot } }],
+ });
+ const workspace = await runtime.open(registration);
+ const first = await workspace.refresh();
+ const start = first.graph.nodes[0]!.ref;
+ await fs.rm(sourceRoot, { recursive: true, force: true });
+
+ await expect(workspace.refresh()).rejects.toMatchObject({ code: 'NO_SOURCE_OBSERVED' });
+ expect(first.graph.traverse(start, { maxDepth: 0, maxNodes: 1 })).toEqual([start]);
+ expect('currentSnapshot' in workspace).toBe(false);
+ expect('graph' in workspace).toBe(false);
+ } finally {
+ await runtime.close();
+ await fs.rm(sourceRoot, { recursive: true, force: true });
+ }
+ });
+
+ it('assigns a new snapshot ID while retaining an equal fingerprint for identical observations', async () => {
+ const sourceRoot = await createFixtureRepository('ste-runtime-repeatable-refresh');
+ const runtime = createRuntime();
+ try {
+ const registration = await runtime.createRegistration({
+ repositories: [{ source: { kind: 'local', path: sourceRoot } }],
+ });
+ const workspace = await runtime.open(registration);
+ const first = await workspace.refresh();
+ const second = await workspace.refresh();
+ expect(second.snapshotId).not.toBe(first.snapshotId);
+ expect(second.observationFingerprint).toBe(first.observationFingerprint);
+ } finally {
+ await runtime.close();
+ await fs.rm(sourceRoot, { recursive: true, force: true });
+ }
+ });
+
+ it('observes multiple repositories in one workspace with separate provenance', async () => {
+ const repositoryA = await createFixtureRepository('ste-runtime-multi-a');
+ const repositoryB = await createFixtureRepository('ste-runtime-multi-b');
+ const runtime = createRuntime();
+ try {
+ const registration = await runtime.createRegistration({
+ repositories: [
+ { source: { kind: 'local', path: repositoryA } },
+ { source: { kind: 'local', path: repositoryB } },
+ ],
+ });
+ const snapshot = await (await runtime.open(registration)).refresh();
+ const repositoryIds = new Set(registration.definition.repositories.map(repository => repository.repositoryId));
+ const observedIds = new Set(snapshot.repositoryObservations
+ .filter(observation => observation.status === 'observed')
+ .map(observation => observation.repositoryId));
+ expect(snapshot.status).toBe('complete');
+ expect(observedIds).toEqual(repositoryIds);
+ expect(new Set(snapshot.graph.nodes.flatMap(node => node.provenance.sources.map(source => source.repositoryId)))).toEqual(repositoryIds);
+ } finally {
+ await runtime.close();
+ await fs.rm(repositoryA, { recursive: true, force: true });
+ await fs.rm(repositoryB, { recursive: true, force: true });
+ }
+ });
+});
diff --git a/src/public/runtime.ts b/src/public/runtime.ts
new file mode 100644
index 0000000..8aae5d5
--- /dev/null
+++ b/src/public/runtime.ts
@@ -0,0 +1,573 @@
+import crypto from 'node:crypto';
+import fs from 'node:fs/promises';
+import os from 'node:os';
+import path from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+import type { WorkspaceReconResult } from '../workspace/workspace-recon.js';
+import {
+ canonicalDefinition,
+ definitionRevision,
+ normalizeLocalSourcePath,
+ observationFingerprint,
+ PUBLIC_RUNTIME_CONTRACT_VERSION,
+} from './canonical.js';
+import { RefreshError, RuntimeContractError } from './errors.js';
+import { createGraphProjection } from './graph.js';
+import type {
+ CreateRegistrationInput,
+ EntityProvenance,
+ GraphNode,
+ GraphRelationship,
+ LocalRepositorySource,
+ RegisteredRepositoryMetadata,
+ RepositoryDefinition,
+ RepositoryDisplayMetadata,
+ RepositoryId,
+ RepositoryObservation,
+ ReviseRegistrationInput,
+ Runtime,
+ RuntimeCapabilityManifest,
+ RuntimeDiagnostic,
+ RuntimeSnapshot,
+ SnapshotId,
+ SourceProvenance,
+ WorkspaceDefinition,
+ WorkspaceHandle,
+ WorkspaceId,
+ WorkspaceRegistration,
+} from './types.js';
+
+interface LegacyNode {
+ id: string;
+ type: string;
+ name: string;
+ repo?: string;
+ attributes?: Record;
+ provenance?: {
+ source_path?: string;
+ source_ref?: string;
+ repo?: string;
+ };
+}
+
+interface LegacyEdge {
+ from: string;
+ to: string;
+ verb: string;
+ provenance?: {
+ source_path?: string;
+ source_ref?: string;
+ repo?: string;
+ source_repo?: string;
+ target_repo?: string;
+ evidence?: string;
+ };
+}
+
+interface LegacyGraph {
+ nodes?: LegacyNode[];
+ edges?: LegacyEdge[];
+}
+
+const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+function uuidv7(): string {
+ const bytes = crypto.randomBytes(16);
+ const timestamp = BigInt(Date.now());
+ for (let index = 5; index >= 0; index -= 1) {
+ bytes[index] = Number(timestamp >> BigInt((5 - index) * 8)) & 0xff;
+ }
+ bytes[6] = (bytes[6] & 0x0f) | 0x70;
+ bytes[8] = (bytes[8] & 0x3f) | 0x80;
+ const hex = bytes.toString('hex');
+ return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
+}
+
+function isUuidV7(value: string): boolean {
+ return UUID_V7.test(value);
+}
+
+function deepFreeze(value: T): T {
+ if (value && typeof value === 'object' && !Object.isFrozen(value)) {
+ Object.freeze(value);
+ for (const child of Object.values(value as Record)) {
+ deepFreeze(child);
+ }
+ }
+ return value;
+}
+
+function cloneDisplay(value: RepositoryDisplayMetadata | undefined): RepositoryDisplayMetadata | undefined {
+ return value ? { ...value } : undefined;
+}
+
+function normalizeSource(source: LocalRepositorySource): LocalRepositorySource {
+ if (source.kind !== 'local' || typeof source.path !== 'string' || source.path.trim().length === 0) {
+ throw new RuntimeContractError('INVALID_REPOSITORY_SOURCE', 'Only non-empty local repository paths are supported');
+ }
+ return { kind: 'local', path: normalizeLocalSourcePath(source.path) };
+}
+
+function validateRepositoryId(value: string): asserts value is RepositoryId {
+ if (!isUuidV7(value)) {
+ throw new RuntimeContractError('INVALID_REPOSITORY_ID', `RepositoryId is not a UUIDv7: ${value}`);
+ }
+}
+
+function validateWorkspaceId(value: string): asserts value is WorkspaceId {
+ if (!isUuidV7(value)) {
+ throw new RuntimeContractError('INVALID_WORKSPACE_ID', `WorkspaceId is not a UUIDv7: ${value}`);
+ }
+}
+
+function validateSnapshotId(value: string): asserts value is SnapshotId {
+ if (!isUuidV7(value)) {
+ throw new RuntimeContractError('INVALID_SNAPSHOT_ID', `SnapshotId is not a UUIDv7: ${value}`);
+ }
+}
+
+function sortedByRepositoryId(values: readonly T[]): T[] {
+ return [...values].sort((a, b) => a.repositoryId.localeCompare(b.repositoryId));
+}
+
+function createDefinition(repositories: readonly RepositoryDefinition[]): WorkspaceDefinition {
+ if (repositories.length === 0) {
+ throw new RuntimeContractError('EMPTY_WORKSPACE', 'A workspace must contain at least one repository');
+ }
+ const ids = new Set();
+ for (const repository of repositories) {
+ validateRepositoryId(repository.repositoryId);
+ if (ids.has(repository.repositoryId)) {
+ throw new RuntimeContractError('DUPLICATE_REPOSITORY_ID', `RepositoryId is duplicated: ${repository.repositoryId}`);
+ }
+ ids.add(repository.repositoryId);
+ }
+ return { repositories: sortedByRepositoryId(repositories).map(repository => ({
+ repositoryId: repository.repositoryId,
+ source: normalizeSource(repository.source),
+ })) };
+}
+
+function buildRegistration(
+ workspaceId: WorkspaceId,
+ definition: WorkspaceDefinition,
+ display: WorkspaceRegistration['display'],
+ metadata: readonly RegisteredRepositoryMetadata[],
+): WorkspaceRegistration {
+ const normalizedDefinition = createDefinition(definition.repositories);
+ const registration: WorkspaceRegistration = {
+ workspaceId,
+ definition: normalizedDefinition,
+ definitionRevision: definitionRevision(normalizedDefinition),
+ display: display ? { ...display } : undefined,
+ repositories: sortedByRepositoryId(metadata).map(entry => ({
+ repositoryId: entry.repositoryId,
+ display: cloneDisplay(entry.display),
+ })),
+ };
+ return deepFreeze(registration);
+}
+
+function validateRegistration(registration: WorkspaceRegistration): WorkspaceRegistration {
+ if (!registration || typeof registration !== 'object') {
+ throw new RuntimeContractError('INVALID_REGISTRATION', 'A complete WorkspaceRegistration is required');
+ }
+ validateWorkspaceId(registration.workspaceId);
+ const normalizedDefinition = createDefinition(registration.definition.repositories);
+ const expected = definitionRevision(normalizedDefinition);
+ if (registration.definitionRevision !== expected) {
+ throw new RuntimeContractError(
+ 'DEFINITION_REVISION_MISMATCH',
+ `Workspace definition revision does not match canonical definition (expected ${expected})`,
+ );
+ }
+ const ids = new Set(normalizedDefinition.repositories.map(repository => repository.repositoryId));
+ for (const metadata of registration.repositories) {
+ validateRepositoryId(metadata.repositoryId);
+ if (!ids.has(metadata.repositoryId)) {
+ throw new RuntimeContractError(
+ 'REGISTRATION_METADATA_MISMATCH',
+ `Repository metadata references a repository outside the definition: ${metadata.repositoryId}`,
+ );
+ }
+ }
+ return buildRegistration(registration.workspaceId, normalizedDefinition, registration.display, registration.repositories);
+}
+
+function executionKey(index: number): string {
+ return `repo-${index + 1}`;
+}
+
+function sourceLocator(sourcePath?: string, sourceRef?: string): string | undefined {
+ if (!sourcePath && !sourceRef) return undefined;
+ if (!sourceRef) return sourcePath;
+ if (!sourcePath) return sourceRef;
+ return `${sourcePath}#${sourceRef}`;
+}
+
+function repositoryIdFor(
+ key: string | undefined,
+ executionToRepository: ReadonlyMap,
+): RepositoryId | undefined {
+ return key ? executionToRepository.get(key) : undefined;
+}
+
+async function readLegacyGraph(raw: string): Promise {
+ const { default: yaml } = await import('js-yaml');
+ const parsed = yaml.load(raw) as LegacyGraph | null;
+ return parsed ?? {};
+}
+
+async function pathStatus(sourcePath: string): Promise<'present' | 'orphaned' | 'unavailable'> {
+ try {
+ const stat = await fs.stat(sourcePath);
+ return stat.isDirectory() ? 'present' : 'unavailable';
+ } catch (error) {
+ const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : '';
+ return code === 'ENOENT' || code === 'ENOTDIR' ? 'orphaned' : 'unavailable';
+ }
+}
+
+function toDiagnostic(code: string, message: string, repositoryIds?: readonly RepositoryId[]): RuntimeDiagnostic {
+ return { code, message, repositoryIds };
+}
+
+function graphFromLegacy(
+ workspaceId: WorkspaceId,
+ snapshotId: SnapshotId,
+ graph: LegacyGraph,
+ executionToRepository: ReadonlyMap,
+): ReturnType {
+ const nodes: GraphNode[] = [];
+ const nodeById = new Map();
+
+ for (const node of graph.nodes ?? []) {
+ const repoKey = node.provenance?.repo ?? node.repo;
+ const repositoryId = repositoryIdFor(repoKey, executionToRepository);
+ if (!repositoryId || nodeById.has(node.id)) continue;
+ const provenance: EntityProvenance = {
+ snapshotId,
+ sources: [{
+ repositoryId,
+ sourceLocator: sourceLocator(node.provenance?.source_path, node.provenance?.source_ref),
+ }],
+ };
+ const projected: GraphNode = {
+ ref: { workspaceId, snapshotId, nodeId: node.id },
+ type: node.type,
+ name: node.name,
+ provenance,
+ attributes: node.attributes,
+ };
+ nodes.push(projected);
+ nodeById.set(node.id, projected);
+ }
+
+ const relationships: GraphRelationship[] = [];
+ const relationshipKeys = new Set();
+ for (const edge of graph.edges ?? []) {
+ const source = nodeById.get(edge.from);
+ const target = nodeById.get(edge.to);
+ if (!source || !target) continue;
+
+ const sourceRepository = repositoryIdFor(edge.provenance?.source_repo, executionToRepository)
+ ?? source.provenance.sources[0]?.repositoryId;
+ const targetRepository = repositoryIdFor(edge.provenance?.target_repo, executionToRepository)
+ ?? target.provenance.sources[0]?.repositoryId;
+ const sourceRecords: SourceProvenance[] = [];
+ if (sourceRepository) sourceRecords.push({ repositoryId: sourceRepository });
+ if (targetRepository && targetRepository !== sourceRepository) sourceRecords.push({ repositoryId: targetRepository });
+ for (const record of source.provenance.sources) {
+ if (!sourceRecords.some(existing => existing.repositoryId === record.repositoryId)) sourceRecords.push(record);
+ }
+ for (const record of target.provenance.sources) {
+ if (!sourceRecords.some(existing => existing.repositoryId === record.repositoryId)) sourceRecords.push(record);
+ }
+
+ const key = `${edge.from}|${edge.to}|${edge.verb}`;
+ if (relationshipKeys.has(key)) continue;
+ relationshipKeys.add(key);
+ relationships.push({
+ source: source.ref,
+ target: target.ref,
+ verb: edge.verb,
+ provenance: {
+ snapshotId,
+ sources: sourceRecords,
+ evidence: edge.provenance?.evidence ?? sourceLocator(edge.provenance?.source_path, edge.provenance?.source_ref),
+ },
+ });
+ }
+
+ return createGraphProjection(workspaceId, snapshotId, nodes, relationships);
+}
+
+function currentObservationValue(
+ registration: WorkspaceRegistration,
+ observations: readonly RepositoryObservation[],
+ graph: ReturnType,
+): unknown {
+ return {
+ definitionRevision: registration.definitionRevision,
+ repositories: observations.map(observation => ({
+ repositoryId: observation.repositoryId,
+ status: observation.status,
+ diagnostic: observation.diagnostic,
+ })),
+ nodes: graph.nodes.map(node => ({
+ ref: { workspaceId: node.ref.workspaceId, nodeId: node.ref.nodeId },
+ type: node.type,
+ name: node.name,
+ provenance: { sources: node.provenance.sources },
+ attributes: node.attributes,
+ })),
+ relationships: graph.relationships.map(relationship => ({
+ source: { workspaceId: relationship.source.workspaceId, nodeId: relationship.source.nodeId },
+ target: { workspaceId: relationship.target.workspaceId, nodeId: relationship.target.nodeId },
+ verb: relationship.verb,
+ provenance: { sources: relationship.provenance.sources, evidence: relationship.provenance.evidence },
+ })),
+ };
+}
+
+async function writeWorkspaceManifest(
+ workspaceRoot: string,
+ registration: WorkspaceRegistration,
+): Promise<{ manifestPath: string; executionToRepository: Map }> {
+ const repositories = sortedByRepositoryId(registration.definition.repositories);
+ const executionToRepository = new Map();
+ const manifestRepos = repositories.map((repository, index) => {
+ const key = executionKey(index);
+ executionToRepository.set(key, repository.repositoryId);
+ return {
+ name: key,
+ path: repository.source.path,
+ kind: 'service',
+ lang: 'unknown',
+ };
+ });
+ const manifestPath = path.join(workspaceRoot, 'workspace.yaml');
+ const { default: yaml } = await import('js-yaml');
+ await fs.writeFile(
+ manifestPath,
+ yaml.dump({ schema_version: '1.0', output_dir: '.workspace-graph', repos: manifestRepos }),
+ 'utf8',
+ );
+ return { manifestPath, executionToRepository };
+}
+
+function observationsForResult(
+ registration: WorkspaceRegistration,
+ result: WorkspaceReconResult,
+ executionToRepository: ReadonlyMap,
+ initialStatuses: ReadonlyMap,
+): { observations: RepositoryObservation[]; observedCount: number; diagnostics: RuntimeDiagnostic[] } {
+ const observations: RepositoryObservation[] = [];
+ const diagnostics: RuntimeDiagnostic[] = [];
+ for (const repository of sortedByRepositoryId(registration.definition.repositories)) {
+ const key = [...executionToRepository.entries()].find(([, id]) => id === repository.repositoryId)?.[0];
+ const repoResult = result.repos.find(entry => entry.name === key);
+ const initial = initialStatuses.get(repository.repositoryId) ?? 'unavailable';
+ if (repoResult?.status === 'success') {
+ observations.push({
+ repositoryId: repository.repositoryId,
+ status: 'observed',
+ sourceFingerprint: repoResult.contentHash,
+ });
+ continue;
+ }
+ const status = initial === 'orphaned' ? 'orphaned' : 'unavailable';
+ const message = repoResult?.error?.message ?? `Repository ${repository.repositoryId} was not observed`;
+ observations.push({ repositoryId: repository.repositoryId, status, diagnostic: message });
+ diagnostics.push(toDiagnostic(status === 'orphaned' ? 'REPOSITORY_ORPHANED' : 'REPOSITORY_UNAVAILABLE', message, [repository.repositoryId]));
+ }
+ return {
+ observations,
+ observedCount: observations.filter(observation => observation.status === 'observed').length,
+ diagnostics,
+ };
+}
+
+export function createRuntime(): Runtime {
+ const temporaryRoots = new Set();
+ let closed = false;
+
+ const capabilities: RuntimeCapabilityManifest = Object.freeze({
+ contractVersion: PUBLIC_RUNTIME_CONTRACT_VERSION,
+ mechanical: true,
+ supportedSourceKinds: ['local'] as const,
+ federation: false,
+ });
+
+ const ensureOpen = (): void => {
+ if (closed) throw new RuntimeContractError('RUNTIME_CLOSED', 'Runtime has been closed');
+ };
+
+ const createRegistration = async (input: CreateRegistrationInput): Promise => {
+ ensureOpen();
+ if (!input || !Array.isArray(input.repositories) || input.repositories.length === 0) {
+ throw new RuntimeContractError('EMPTY_WORKSPACE', 'A workspace must contain at least one repository');
+ }
+ const ordered = [...input.repositories].sort((a, b) => normalizeLocalSourcePath(a.source.path).localeCompare(normalizeLocalSourcePath(b.source.path)));
+ const definitions: RepositoryDefinition[] = [];
+ const metadata: RegisteredRepositoryMetadata[] = [];
+ for (const repository of ordered) {
+ const repositoryId = uuidv7() as RepositoryId;
+ definitions.push({ repositoryId, source: normalizeSource(repository.source) });
+ metadata.push({ repositoryId, display: cloneDisplay(repository.display) });
+ }
+ return buildRegistration(uuidv7() as WorkspaceId, { repositories: definitions }, input.display, metadata);
+ };
+
+ const reviseRegistration = async (
+ registrationInput: WorkspaceRegistration,
+ input: ReviseRegistrationInput,
+ ): Promise => {
+ ensureOpen();
+ const registration = validateRegistration(registrationInput);
+ const existingIds = new Set(registration.definition.repositories.map(repository => repository.repositoryId));
+ const retainedIds = new Set();
+ const removedIds = new Set();
+ for (const retained of input.retain) {
+ validateRepositoryId(retained.repositoryId);
+ if (!existingIds.has(retained.repositoryId)) {
+ throw new RuntimeContractError('UNKNOWN_REPOSITORY_ID', `Cannot retain unknown repository ${retained.repositoryId}`);
+ }
+ if (!retainedIds.add(retained.repositoryId)) {
+ throw new RuntimeContractError('DUPLICATE_REPOSITORY_ID', `Repository is retained twice: ${retained.repositoryId}`);
+ }
+ }
+ for (const removed of input.remove) {
+ validateRepositoryId(removed);
+ if (!existingIds.has(removed) || retainedIds.has(removed) || !removedIds.add(removed)) {
+ throw new RuntimeContractError('INVALID_REPOSITORY_REVISION', `Invalid repository removal: ${removed}`);
+ }
+ }
+ if (retainedIds.size + removedIds.size !== existingIds.size) {
+ throw new RuntimeContractError('INCOMPLETE_REPOSITORY_REVISION', 'Every existing repository must be retained or explicitly removed');
+ }
+
+ const retainedDefinitions = input.retain.map(repository => ({
+ repositoryId: repository.repositoryId,
+ source: normalizeSource(repository.source),
+ }));
+ const orderedAdds = [...input.add].sort((a, b) => normalizeLocalSourcePath(a.source.path).localeCompare(normalizeLocalSourcePath(b.source.path)));
+ const addedDefinitions: RepositoryDefinition[] = [];
+ const addedMetadata: RegisteredRepositoryMetadata[] = [];
+ for (const repository of orderedAdds) {
+ const repositoryId = uuidv7() as RepositoryId;
+ addedDefinitions.push({ repositoryId, source: normalizeSource(repository.source) });
+ addedMetadata.push({ repositoryId, display: cloneDisplay(repository.display) });
+ }
+ const currentMetadata = new Map(registration.repositories.map(entry => [entry.repositoryId, entry.display]));
+ const metadata: RegisteredRepositoryMetadata[] = [
+ ...retainedDefinitions.map(repository => ({
+ repositoryId: repository.repositoryId,
+ display: cloneDisplay(input.retain.find(entry => entry.repositoryId === repository.repositoryId)?.display ?? currentMetadata.get(repository.repositoryId)),
+ })),
+ ...addedMetadata,
+ ];
+ const revised = buildRegistration(
+ registration.workspaceId,
+ { repositories: [...retainedDefinitions, ...addedDefinitions] },
+ input.display ?? registration.display,
+ metadata,
+ );
+ return revised;
+ };
+
+ const open = async (registrationInput: WorkspaceRegistration): Promise => {
+ ensureOpen();
+ const registration = validateRegistration(registrationInput);
+ return {
+ registration,
+ refresh: async (): Promise => {
+ ensureOpen();
+ const refreshRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ste-runtime-public-refresh-'));
+ temporaryRoots.add(refreshRoot);
+ const { manifestPath, executionToRepository } = await writeWorkspaceManifest(refreshRoot, registration);
+ const initialStatuses = new Map();
+ for (const repository of registration.definition.repositories) {
+ initialStatuses.set(repository.repositoryId, await pathStatus(repository.source.path));
+ }
+
+ try {
+ const [{ executeWorkspaceRecon }, { preflightWorkspaceGraphIdentity }] = await Promise.all([
+ import('../workspace/workspace-recon.js'),
+ import('../workspace/workspace-merge.js'),
+ ]);
+ const result = await executeWorkspaceRecon({
+ workspacePath: manifestPath,
+ mode: 'full',
+ runtimeDir: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'),
+ failOnAnyError: false,
+ skipUnchanged: false,
+ beforeMerge: outputDir => preflightWorkspaceGraphIdentity(outputDir),
+ });
+ const { observations, observedCount, diagnostics } = observationsForResult(
+ registration,
+ result,
+ executionToRepository,
+ initialStatuses,
+ );
+ if (observedCount === 0) {
+ throw new RefreshError('NO_SOURCE_OBSERVED', 'No registered repository could be observed', diagnostics);
+ }
+
+ const graphRaw = await fs.readFile(path.join(refreshRoot, '.workspace-graph', 'graph.yaml'), 'utf8');
+ const legacyGraph = await readLegacyGraph(graphRaw);
+ const snapshotId = uuidv7() as SnapshotId;
+ validateSnapshotId(snapshotId);
+ const graph = graphFromLegacy(registration.workspaceId, snapshotId, legacyGraph, executionToRepository);
+ const snapshot: RuntimeSnapshot = {
+ snapshotId,
+ workspaceId: registration.workspaceId,
+ definitionRevision: registration.definitionRevision,
+ status: observedCount === registration.definition.repositories.length ? 'complete' : 'partial',
+ observationFingerprint: observationFingerprint(currentObservationValue(registration, observations, graph)),
+ observedAt: new Date().toISOString(),
+ runtimeContractVersion: PUBLIC_RUNTIME_CONTRACT_VERSION,
+ repositoryObservations: observations,
+ graph,
+ diagnostics,
+ };
+ return deepFreeze(snapshot);
+ } catch (error) {
+ if (error instanceof RefreshError) throw error;
+ if (error instanceof Error && error.name === 'WorkspaceIdentityCollisionError' && 'collisions' in error) {
+ const collisions = (error as Error & { collisions: Array<{ id: string; repositories: string[] }> }).collisions;
+ const diagnostics = collisions.map(collision => toDiagnostic(
+ 'ENTITY_ID_COLLISION',
+ `${collision.id} was declared by ${collision.repositories.join(', ')}`,
+ collision.repositories
+ .map(repository => executionToRepository.get(repository))
+ .filter((repositoryId): repositoryId is RepositoryId => repositoryId !== undefined),
+ ));
+ throw new RefreshError('ENTITY_ID_COLLISION', error.message, diagnostics);
+ }
+ const message = error instanceof Error ? error.message : String(error);
+ throw new RefreshError('REFRESH_FAILED', message, [toDiagnostic('REFRESH_FAILED', message)]);
+ } finally {
+ await fs.rm(refreshRoot, { recursive: true, force: true });
+ temporaryRoots.delete(refreshRoot);
+ }
+ },
+ };
+ };
+
+ return {
+ createRegistration,
+ reviseRegistration,
+ open,
+ capabilities: () => capabilities,
+ close: async () => {
+ if (closed) return;
+ closed = true;
+ await Promise.all([...temporaryRoots].map(root => fs.rm(root, { recursive: true, force: true })));
+ temporaryRoots.clear();
+ },
+ };
+}
+
+export { canonicalDefinition, definitionRevision, normalizeLocalSourcePath };
diff --git a/src/public/types.ts b/src/public/types.ts
new file mode 100644
index 0000000..b401d1a
--- /dev/null
+++ b/src/public/types.ts
@@ -0,0 +1,169 @@
+export type WorkspaceId = string & { readonly __workspaceId: unique symbol };
+export type RepositoryId = string & { readonly __repositoryId: unique symbol };
+export type SnapshotId = string & { readonly __snapshotId: unique symbol };
+export type DefinitionRevision = string & { readonly __definitionRevision: unique symbol };
+
+export interface LocalRepositorySource {
+ readonly kind: 'local';
+ readonly path: string;
+}
+
+export interface RepositoryDisplayMetadata {
+ readonly name?: string;
+ readonly alias?: string;
+}
+
+export interface WorkspaceDisplayMetadata {
+ readonly name?: string;
+ readonly alias?: string;
+}
+
+export interface RepositoryDefinition {
+ readonly repositoryId: RepositoryId;
+ readonly source: LocalRepositorySource;
+}
+
+export interface WorkspaceDefinition {
+ readonly repositories: readonly RepositoryDefinition[];
+}
+
+export interface RegisteredRepositoryMetadata {
+ readonly repositoryId: RepositoryId;
+ readonly display?: RepositoryDisplayMetadata;
+}
+
+export interface WorkspaceRegistration {
+ readonly workspaceId: WorkspaceId;
+ readonly definition: WorkspaceDefinition;
+ readonly definitionRevision: DefinitionRevision;
+ readonly display?: WorkspaceDisplayMetadata;
+ readonly repositories: readonly RegisteredRepositoryMetadata[];
+}
+
+export interface CreateRepositoryInput {
+ readonly source: LocalRepositorySource;
+ readonly display?: RepositoryDisplayMetadata;
+}
+
+export interface CreateRegistrationInput {
+ readonly repositories: readonly CreateRepositoryInput[];
+ readonly display?: WorkspaceDisplayMetadata;
+}
+
+export interface RetainedRepositoryInput {
+ readonly repositoryId: RepositoryId;
+ readonly source: LocalRepositorySource;
+ readonly display?: RepositoryDisplayMetadata;
+}
+
+export interface ReviseRegistrationInput {
+ readonly retain: readonly RetainedRepositoryInput[];
+ readonly add: readonly CreateRepositoryInput[];
+ readonly remove: readonly RepositoryId[];
+ readonly display?: WorkspaceDisplayMetadata;
+}
+
+/**
+ * Opaque identity for a node in one immutable graph projection.
+ * Legacy node IDs are projection keys, not durable source/entity identity.
+ */
+export interface GraphNodeRef {
+ readonly workspaceId: WorkspaceId;
+ readonly snapshotId: SnapshotId;
+ readonly nodeId: string;
+}
+
+export interface SourceProvenance {
+ readonly repositoryId: RepositoryId;
+ readonly sourceLocator?: string;
+}
+
+export interface EntityProvenance {
+ readonly snapshotId: SnapshotId;
+ readonly sources: readonly SourceProvenance[];
+}
+
+export interface RelationshipProvenance {
+ readonly snapshotId: SnapshotId;
+ readonly sources: readonly SourceProvenance[];
+ readonly evidence?: string;
+}
+
+export interface GraphNode {
+ readonly ref: GraphNodeRef;
+ readonly type: string;
+ readonly name: string;
+ readonly provenance: EntityProvenance;
+ readonly attributes?: Readonly>;
+}
+
+export interface GraphRelationship {
+ readonly source: GraphNodeRef;
+ readonly target: GraphNodeRef;
+ readonly verb: string;
+ readonly provenance: RelationshipProvenance;
+}
+
+export interface TraversalOptions {
+ readonly maxDepth?: number;
+ readonly maxNodes?: number;
+}
+
+export interface GraphProjection {
+ readonly nodes: readonly GraphNode[];
+ readonly relationships: readonly GraphRelationship[];
+ readonly getNode: (ref: GraphNodeRef) => GraphNode | undefined;
+ readonly traverse: (start: GraphNodeRef, options?: TraversalOptions) => readonly GraphNodeRef[];
+}
+
+export type RepositoryObservationStatus = 'observed' | 'orphaned' | 'unavailable';
+
+export interface RepositoryObservation {
+ readonly repositoryId: RepositoryId;
+ readonly status: RepositoryObservationStatus;
+ readonly sourceFingerprint?: string;
+ readonly diagnostic?: string;
+}
+
+export interface RuntimeDiagnostic {
+ readonly code: string;
+ readonly message: string;
+ readonly repositoryIds?: readonly RepositoryId[];
+}
+
+export interface RuntimeSnapshot {
+ readonly snapshotId: SnapshotId;
+ readonly workspaceId: WorkspaceId;
+ readonly definitionRevision: DefinitionRevision;
+ readonly status: 'complete' | 'partial';
+ readonly observationFingerprint: string;
+ readonly observedAt: string;
+ readonly runtimeContractVersion: string;
+ readonly extractorVersions?: readonly string[];
+ readonly repositoryObservations: readonly RepositoryObservation[];
+ readonly graph: GraphProjection;
+ readonly diagnostics: readonly RuntimeDiagnostic[];
+}
+
+export interface WorkspaceHandle {
+ readonly registration: WorkspaceRegistration;
+ refresh(): Promise;
+}
+
+export interface RuntimeCapabilityManifest {
+ readonly contractVersion: string;
+ readonly mechanical: true;
+ readonly supportedSourceKinds: readonly ['local'];
+ readonly federation: false;
+}
+
+export interface Runtime {
+ createRegistration(input: CreateRegistrationInput): Promise;
+ reviseRegistration(
+ registration: WorkspaceRegistration,
+ input: ReviseRegistrationInput,
+ ): Promise;
+ open(registration: WorkspaceRegistration): Promise;
+ capabilities(): RuntimeCapabilityManifest;
+ close(): Promise;
+}
diff --git a/src/workspace/manifest.ts b/src/workspace/manifest.ts
index cda79c6..1fbf727 100644
--- a/src/workspace/manifest.ts
+++ b/src/workspace/manifest.ts
@@ -111,6 +111,13 @@ function dedupeLanguages(langs: SupportedLanguage[]): SupportedLanguage[] {
return [...new Set(langs)];
}
+function equivalentLocalPath(left: string, right: string): boolean {
+ const normalize = (value: string) => path.normalize(value).replace(/[\\/]+$/, '');
+ const a = normalize(left);
+ const b = normalize(right);
+ return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b;
+}
+
/**
* Map manifest `lang` labels to RECON {@link SupportedLanguage} values.
* Unknown labels fall back to {@link detectLanguages} against the repository root.
@@ -319,7 +326,7 @@ export const buildPerRepoConfig: (
const relativeStateDir = toPosixPath(path.relative(repoAbsPath, stateAbsPath));
const resolvedViaJoin = path.resolve(repoAbsPath, relativeStateDir);
- if (resolvedViaJoin !== stateAbsPath) {
+ if (!equivalentLocalPath(resolvedViaJoin, stateAbsPath)) {
throw new Error(
`Invariant 2 check failed: state path resolution mismatch (expected ${stateAbsPath}, got ${resolvedViaJoin})`,
);
diff --git a/src/workspace/workspace-merge.ts b/src/workspace/workspace-merge.ts
index 03e283f..4bfeacf 100644
--- a/src/workspace/workspace-merge.ts
+++ b/src/workspace/workspace-merge.ts
@@ -34,7 +34,13 @@ export interface MergedEdge {
to: string;
verb: string;
confidence?: string;
- provenance?: { source_path: string; source_ref: string; repo?: string };
+ provenance?:
+ | { source_path: string; source_ref: string; repo?: string }
+ | {
+ source_repo: string;
+ target_repo: string;
+ evidence: string;
+ };
}
export interface UnifiedGraphDoc {
@@ -77,6 +83,66 @@ interface SliceDoc {
}>;
}
+export interface WorkspaceIdentityCollision {
+ id: string;
+ repositories: string[];
+ declarations: Array<{ repository: string; type: string; name: string }>;
+}
+
+export class WorkspaceIdentityCollisionError extends Error {
+ readonly collisions: readonly WorkspaceIdentityCollision[];
+
+ constructor(collisions: readonly WorkspaceIdentityCollision[]) {
+ super(
+ `Workspace entity identity collision before merge: ${collisions
+ .map(c => `${c.id} [${c.repositories.join(', ')}]`)
+ .join('; ')}`,
+ );
+ this.name = 'WorkspaceIdentityCollisionError';
+ this.collisions = collisions;
+ }
+}
+
+/**
+ * Validate workspace-scoped entity IDs before the legacy first-wins merger can
+ * discard a declaration. This is opt-in so existing CLI behavior remains
+ * compatible; the public runtime adapter always enables it.
+ */
+export async function preflightWorkspaceGraphIdentity(outputDir: string): Promise {
+ const slicesDir = path.join(outputDir, 'slices');
+ const declarations = new Map>();
+ let sliceFiles: string[] = [];
+ try {
+ const entries = await fs.readdir(slicesDir);
+ sliceFiles = entries.filter(file => file.endsWith('.yaml')).sort().map(file => path.join(slicesDir, file));
+ } catch {
+ return;
+ }
+
+ for (const slicePath of sliceFiles) {
+ const repository = path.basename(slicePath, '.yaml');
+ const parsed = yaml.load(await fs.readFile(slicePath, 'utf8')) as SliceDoc | null;
+ for (const node of parsed?.nodes ?? []) {
+ if (!node.id || !node.type) continue;
+ const list = declarations.get(node.id) ?? [];
+ list.push({ repository: node.provenance?.repo ?? repository, type: node.type, name: node.name ?? node.id });
+ declarations.set(node.id, list);
+ }
+ }
+
+ const collisions = [...declarations.entries()]
+ .filter(([, entries]) => new Set(entries.map(entry => entry.repository)).size > 1)
+ .map(([id, entries]) => ({
+ id,
+ repositories: [...new Set(entries.map(entry => entry.repository))].sort(),
+ declarations: entries,
+ }));
+
+ if (collisions.length > 0) {
+ throw new WorkspaceIdentityCollisionError(collisions);
+ }
+}
+
async function loadCrossRepoEdges(outputDir: string): Promise {
const edgesPath = path.join(outputDir, 'workspace-edges.yaml');
try {
@@ -190,6 +256,7 @@ export async function mergeWorkspaceGraph(
to: cre.to,
verb: cre.verb,
confidence: cre.confidence,
+ provenance: cre.provenance,
});
}
if (crossRepoEdges.length > 0) {
diff --git a/src/workspace/workspace-recon.ts b/src/workspace/workspace-recon.ts
index 4cc6569..a1158a9 100644
--- a/src/workspace/workspace-recon.ts
+++ b/src/workspace/workspace-recon.ts
@@ -32,6 +32,8 @@ export interface WorkspaceReconOptions {
failOnAnyError?: boolean;
skipUnchanged?: boolean;
timeoutPerRepoMs?: number;
+ /** Public SDK seam; legacy CLI leaves this unset to preserve first-wins behavior. */
+ beforeMerge?: (outputDir: string) => Promise;
}
export interface RepoResult {
@@ -323,6 +325,10 @@ export const executeWorkspaceRecon: (
log(`[workspace-recon] Cross-repo edge analysis failed (non-fatal): ${msg}`);
}
+ if (options.beforeMerge) {
+ await options.beforeMerge(outputRoot);
+ }
+
try {
const mergeResult = await mergeWorkspaceGraph(outputRoot);
log(
diff --git a/test/fixtures/public-consumer/bootstrap.mjs b/test/fixtures/public-consumer/bootstrap.mjs
new file mode 100644
index 0000000..2b4b752
--- /dev/null
+++ b/test/fixtures/public-consumer/bootstrap.mjs
@@ -0,0 +1,4 @@
+import { run } from './dist/consumer.js';
+
+await run(process.argv[2]);
+console.log('public tarball TypeScript consumer proof passed');
diff --git a/test/fixtures/public-consumer/consumer.ts b/test/fixtures/public-consumer/consumer.ts
new file mode 100644
index 0000000..507277b
--- /dev/null
+++ b/test/fixtures/public-consumer/consumer.ts
@@ -0,0 +1,31 @@
+import { createRuntime } from 'ste-runtime';
+import type { GraphNodeRef } from 'ste-runtime';
+
+const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
+
+export async function run(sourcePath: string): Promise {
+ const runtime = createRuntime();
+ try {
+ const registration = await runtime.createRegistration({
+ repositories: [{ source: { kind: 'local', path: sourcePath } }],
+ });
+ if (!UUID_V7.test(registration.workspaceId)) throw new Error('WorkspaceId is not UUIDv7');
+ if (runtime.capabilities().mechanical !== true || runtime.capabilities().federation !== false) {
+ throw new Error('Invalid public capability manifest');
+ }
+
+ const workspace = await runtime.open(registration);
+ const snapshot = await workspace.refresh();
+ if (snapshot.workspaceId !== registration.workspaceId || !UUID_V7.test(snapshot.snapshotId)) {
+ throw new Error('Snapshot identity did not bind to the registration');
+ }
+ const start: GraphNodeRef | undefined = snapshot.graph.nodes[0]?.ref;
+ if (!start) throw new Error('Packed consumer received no graph node');
+ const traversed = snapshot.graph.traverse(start, { maxDepth: 0, maxNodes: 1 });
+ if (traversed.length !== 1 || traversed[0]?.snapshotId !== snapshot.snapshotId) {
+ throw new Error('Snapshot-bound graph traversal failed');
+ }
+ } finally {
+ await runtime.close();
+ }
+}
diff --git a/test/fixtures/public-consumer/package.json b/test/fixtures/public-consumer/package.json
new file mode 100644
index 0000000..7569438
--- /dev/null
+++ b/test/fixtures/public-consumer/package.json
@@ -0,0 +1,8 @@
+{
+ "name": "ste-runtime-public-consumer-fixture",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "tsc"
+ }
+}
diff --git a/test/fixtures/public-consumer/tsconfig.json b/test/fixtures/public-consumer/tsconfig.json
new file mode 100644
index 0000000..3b5ef7d
--- /dev/null
+++ b/test/fixtures/public-consumer/tsconfig.json
@@ -0,0 +1,11 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "NodeNext",
+ "moduleResolution": "NodeNext",
+ "strict": true,
+ "skipLibCheck": true,
+ "outDir": "dist"
+ },
+ "include": ["consumer.ts"]
+}
diff --git a/vitest.config.ts b/vitest.config.ts
index c6f2257..d19d389 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -1,4 +1,5 @@
import { defineConfig } from 'vitest/config';
+import { fileURLToPath } from 'node:url';
export default defineConfig({
test: {
@@ -39,7 +40,7 @@ export default defineConfig({
// Resolve aliases for cleaner imports
alias: {
- '@/': new URL('./src/', import.meta.url).pathname,
+ '@/': fileURLToPath(new URL('./src/', import.meta.url)),
},
// Pool configuration: use 'forks' for reliable ESM mocking on Linux CI