diff --git a/.gitignore b/.gitignore
index c1a7afd..ebf51ed 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,3 +1,5 @@
node_modules/
npm-debug.log*
*.tmp
+.genesis/
+coverage/
diff --git a/Genesis Configuration.md b/Genesis Configuration.md
index 854babf..0baf488 100644
--- a/Genesis Configuration.md
+++ b/Genesis Configuration.md
@@ -3,7 +3,7 @@
Policy-Version: 2.0.0
Authority: Explanatory
-This is a non-normative operator guide. [genesis.yaml](genesis.yaml) is the normative manifest, and its referenced YAML is normative policy. YAML wins over Markdown whenever valid sources differ. Stop and escalate if normative sources are missing, invalid, contradictory, ambiguous, expired, or revoked.
+This is a non-normative CLI runtime guide. [genesis.yaml](genesis.yaml) is the normative manifest, and its referenced YAML is normative policy. YAML wins over Markdown whenever valid sources differ. Stop and escalate if normative sources are missing, invalid, contradictory, ambiguous, expired, or revoked.
## Repository map
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..d1d6794
--- /dev/null
+++ b/README.md
@@ -0,0 +1,616 @@
+# Genesis 2.0
+
+
+
+**A local-first, human-governed engine for turning business opportunities into evidence-backed experiment plans.**
+
+[](https://nodejs.org/)
+[](./scripts/validate-genesis.mjs)
+[](#how-data-is-stored)
+[](./tests/no-network.test.mjs)
+[](./Genesis.md)
+
+
+
+> Genesis helps you define an opportunity, preserve evidence and counterevidence, inspect decision status, and preregister a bounded validation experiment. Every write is shown as a proposal and requires explicit confirmation.
+
+Policy-Version: 2.0.0
+Authority: Explanatory
+
+## Table of contents
+
+- [What Genesis does](#what-genesis-does)
+- [What Genesis does not do](#what-genesis-does-not-do)
+- [How the engine works](#how-the-engine-works)
+- [Requirements](#requirements)
+- [Quick start](#quick-start)
+- [Complete walkthrough](#complete-walkthrough)
+- [Command reference](#command-reference)
+- [How data is stored](#how-data-is-stored)
+- [Status and metrics](#status-and-metrics)
+- [Safety, authority, and privacy](#safety-authority-and-privacy)
+- [Recovery and troubleshooting](#recovery-and-troubleshooting)
+- [Repository architecture](#repository-architecture)
+- [Policy and record model](#policy-and-record-model)
+- [Development and verification](#development-and-verification)
+- [Current limitations](#current-limitations)
+- [Project status and next steps](#project-status-and-next-steps)
+
+## What Genesis does
+
+Genesis 2.0 currently provides a working interactive command-line workflow for the **Discover → experiment planning** part of a business lifecycle.
+
+It can:
+
+- register a business opportunity with a target customer, problem, hypothesis, confidence, alternatives, expected outcome, metric, owner, and review date;
+- capture an initial evidence item while creating the opportunity;
+- append supporting or contradicting evidence with source references and provenance;
+- preserve decisions, evidence, and experiments as immutable, versioned YAML records;
+- require confirmation before writing any proposed record;
+- enforce JSON Schema and policy-derived workflow gates;
+- calculate early discovery and preregistration metrics;
+- create a complete validation-experiment proposal with explicit cash, labor, duration, data, and risk limits;
+- stop at `approval_pending`, keeping experiment approval and execution separate;
+- maintain a fast local SQLite projection; and
+- rebuild that projection entirely from canonical YAML records.
+
+Genesis is useful when you want a disciplined, auditable way to answer:
+
+1. Who is the customer?
+2. What real problem are we trying to solve?
+3. What evidence supports or contradicts our belief?
+4. What bounded experiment would change the decision?
+5. What requires explicit human approval before anything proceeds?
+
+## What Genesis does not do
+
+The CLI stops at `approval_pending`. It does not automatically research, contact customers, execute experiments, build products, deploy software, bill customers, or operate a business.
+
+It also does not currently provide:
+
+- a graphical or web interface;
+- autonomous agents or external API calls;
+- automatic approval or authority inference;
+- workflow execution beyond experiment preregistration;
+- experiment measurement, reflection, closure, or outcome selection commands;
+- customer relationship management, outreach, deployment, billing, or production operations; or
+- multi-user synchronization or a hosted database.
+
+The policy layer describes a broader governed business lifecycle. The implemented CLI deliberately covers only the first bounded slice.
+
+## How the engine works
+
+```mermaid
+flowchart LR
+ U[Human operator] -->|answers prompts| C[Genesis CLI]
+ C --> P[Proposal preview]
+ P -->|explicit yes| V[Schema and policy checks]
+ P -->|no| X[No change]
+ V -->|valid| Y[(Immutable YAML records)]
+ V -->|invalid| E[Actionable error\ncode · path · correction · escalation]
+ Y --> S[(SQLite projection)]
+ Y --> M[Status and metrics]
+ S --> M
+ Y -->|rebuild-index| S
+```
+
+The design has four important properties:
+
+- **Local-first:** normal CLI operation uses no network-capable imports or `fetch` calls.
+- **Human-confirmed:** every mutation displays the proposed record before asking whether to save it.
+- **Append-only:** a prior YAML record is never silently overwritten; changes create a new numbered version.
+- **Recoverable:** SQLite is derived data. Canonical YAML remains usable if the projection is missing or stale.
+
+The current user journey is:
+
+```mermaid
+stateDiagram-v2
+ [*] --> Discover: genesis start-business
+ Discover --> Discover: genesis add-evidence
+ Discover --> ExperimentPlan: discover gate passes
+ ExperimentPlan --> ApprovalPending: genesis plan-experiment
+ ApprovalPending --> ApprovalPending: genesis status
+ note right of ApprovalPending
+ Current CLI boundary.
+ Approval and execution remain human-controlled.
+ end note
+```
+
+## Requirements
+
+- Node.js **22 or newer**
+- npm
+- A local filesystem supported by `better-sqlite3`
+
+Check your versions:
+
+```bash
+node --version
+npm --version
+```
+
+## Quick start
+
+Clone and install the locked dependencies:
+
+```bash
+git clone https://github.com/zee-cpu/Genesis.git
+cd Genesis
+npm ci
+```
+
+See the available commands:
+
+```bash
+node bin/genesis.mjs --help
+```
+
+Run directly from this repository:
+
+```bash
+npm start
+```
+
+For normal use from another project directory, link the local executable once:
+
+```bash
+npm link
+mkdir my-opportunity-workspace
+cd my-opportunity-workspace
+genesis --help
+```
+
+Genesis always creates its `.genesis/` workspace in the **current working directory**. Run commands from the directory that should own the business records.
+
+Start your first opportunity:
+
+```bash
+genesis start-business
+```
+
+The CLI asks questions, prints the complete proposed records, and finishes with:
+
+```text
+Save this immutable record? [y/N]
+```
+
+Nothing is saved unless you explicitly answer `y`, `yes`, `true`, or `1`.
+
+## Complete walkthrough
+
+The following example evaluates whether an order-reconciliation tool is worth testing with independent bakery owners.
+
+### 1. Register the opportunity
+
+```bash
+genesis start-business
+```
+
+You will be prompted for:
+
+| Input | Meaning | Example |
+|---|---|---|
+| Business ID | Stable URL/file-safe opportunity identifier | `bakery` |
+| Target customer | Specific customer segment | `Independent bakery owners` |
+| Problem | Observable customer problem | `Weekly order reconciliation takes too long` |
+| Hypothesis | Belief that a test could support or weaken | `A clearer order view reduces reconciliation time` |
+| Confidence | Current probability-like belief from 0 to 1 | `0.55` |
+| Source reference | Traceable evidence pointer | `interview://owner-1` |
+| Evidence summary | Short factual summary | `Owner spends two hours reconciling orders weekly` |
+| Stance | Whether the item supports or contradicts the hypothesis | `support` |
+| Provenance | How the evidence was obtained | `Interview note` |
+| Privacy classification | Handling class for the record | `internal` |
+| Counterevidence | Known objections or contrary observations | `Learning curve may offset savings` |
+| Alternatives | Competing options, including doing nothing | `manual process, spreadsheet template` |
+| Expected outcome | Outcome that would matter | `Reconciliation takes under one hour` |
+| Metric | Measure used to judge the belief | `weekly_reconciliation_minutes` |
+| Decision | Decision this discovery work supports | `run_bounded_validation` |
+| Owner | Accountable role or person | `research` |
+| Review date | When the decision should be revisited | `2026-07-24T12:00:00Z` |
+
+After confirmation, Genesis writes two records:
+
+```text
+.genesis/records/evidence/bakery-evidence-001.v0001.yaml
+.genesis/records/decisions/bakery-decision.v0001.yaml
+```
+
+### 2. Add more evidence
+
+```bash
+genesis add-evidence bakery
+```
+
+Record both support and contradiction. Contradicting evidence is not treated as failure; it is preserved so the decision can change when reality changes.
+
+After confirmation, Genesis appends one evidence record and creates a new decision version:
+
+```text
+.genesis/records/evidence/bakery-evidence-002.v0002.yaml
+.genesis/records/decisions/bakery-decision.v0002.yaml
+```
+
+### 3. Inspect status
+
+```bash
+genesis status bakery
+```
+
+Typical output includes:
+
+```text
+Business ID: bakery
+State: discover
+Next command: plan-experiment
+Decision versions: 2
+Experiment versions: 0
+Evidence count: 2
+Supporting evidence: 1
+Contradicting evidence: 1
+Discover gate: passed
+Projection consistent: yes
+```
+
+The Discover gate requires a target customer, problem, hypothesis, and at least one confirmed evidence entry.
+
+### 4. Preregister the experiment
+
+```bash
+genesis plan-experiment bakery
+```
+
+Genesis asks you to define the experiment before results exist:
+
+- the decision the experiment supports;
+- owner, baseline, and comparison method;
+- exact metric formula, population, denominator, and data source;
+- expected outcome and minimum meaningful effect;
+- failure and stop conditions;
+- maximum cash, labor hours, and duration;
+- permitted data classes and risk level;
+- decision date; and
+- allowed outcomes: `scale`, `pivot`, `learning_lab`, `archive`, or `kill`.
+
+After confirmation, Genesis writes:
+
+```text
+.genesis/records/experiments/bakery-experiment.v0001.yaml
+```
+
+The opportunity then enters `approval_pending`. Version 2.0 intentionally stops here; it does not mistake a complete plan for approval or execution.
+
+### 5. Recover the index if needed
+
+```bash
+genesis rebuild-index
+genesis status bakery
+```
+
+This validates every canonical YAML record and replaces the SQLite projection with a clean rebuild.
+
+## Command reference
+
+| Command | Purpose | Writes records? | Expected end state |
+|---|---|---:|---|
+| `genesis start-business` | Create an opportunity, its first decision, and initial evidence | Yes, after confirmation | `discover` |
+| `genesis add-evidence ` | Add evidence and version the associated decision | Yes, after confirmation | `discover` |
+| `genesis status ` | Show state, gates, metrics, limits, blocked commands, and projection health | No | Unchanged |
+| `genesis plan-experiment ` | Create a complete validation-experiment preregistration | Yes, after confirmation | `approval_pending` |
+| `genesis rebuild-index` | Validate YAML and rebuild SQLite from scratch | Replaces derived index only | Unchanged |
+| `genesis --help` | Print command usage | No | Unchanged |
+
+Exit codes:
+
+| Code | Meaning |
+|---:|---|
+| `0` | Command completed or the user cancelled a proposal |
+| `1` | Validation, workflow, storage, or unexpected execution error |
+| `2` | Unknown command or missing required command argument |
+
+## How data is stored
+
+Genesis creates this structure in the directory where you run it:
+
+```text
+.genesis/
+├── records/
+│ ├── decisions/
+│ │ └── .v0001.yaml
+│ ├── evidence/
+│ │ └── .v0001.yaml
+│ └── experiments/
+│ └── .v0001.yaml
+├── .transactions/ # transient crash-recovery journals, normally empty
+├── genesis.db
+└── workspace.lock # exists only while an operation is active
+```
+
+### Canonical YAML
+
+YAML records are the source of truth. They are:
+
+- schema-validated before persistence;
+- written through a temporary file and atomic rename;
+- permissioned locally (`0600` files and `0700` directories where supported);
+- versioned with `.v0001.yaml`, `.v0002.yaml`, and so on; and
+- protected from accidental overwrite by rejecting an existing version path.
+
+Do not edit historical records to change what happened. Create a new version or a superseding record through the appropriate workflow.
+
+### Rebuildable SQLite projection
+
+`.genesis/genesis.db` is a query-oriented cache containing:
+
+- every projected record version;
+- current opportunity state and latest record references;
+- support and contradiction counts;
+- confidence and lifecycle timestamps; and
+- blocked command events.
+
+SQLite is **not** authoritative. If projection fails after YAML is safely written, Genesis reports `PROJECTION_STALE`; the data remains recoverable with `genesis rebuild-index`.
+
+### Workspace locking
+
+Genesis creates `.genesis/workspace.lock` with exclusive creation while it reads or writes the workspace. A competing operation fails with `WORKSPACE_LOCKED`, preventing concurrent local commands from racing. If a terminated process leaves a well-formed lock behind, Genesis verifies that the recorded PID is no longer active and reclaims the lock automatically. Ambiguous locks fail closed for manual inspection.
+
+## Status and metrics
+
+`genesis status ` combines canonical records with the SQLite projection and reports:
+
+- lifecycle state and next permitted command;
+- decision, evidence, and experiment counts;
+- supporting and contradicting evidence totals;
+- Discover-gate result and blockers;
+- missing experiment-preregistration fields;
+- cash, labor, duration, data, and risk limits;
+- blocked commands grouped by error code;
+- YAML/SQLite projection consistency;
+- discovery duration;
+- time to validation plan;
+- preregistration completeness ratio; and
+- confidence history across decision versions.
+
+These are early workflow metrics, not proof that a business is viable. The broader normative metric definitions live in [`config/metrics-policy.yaml`](config/metrics-policy.yaml).
+
+## Safety, authority, and privacy
+
+Genesis is governed by default-deny policy:
+
+- **No inferred approval.** Silence, previous behavior, authorship, and a completed proposal do not grant authority.
+- **Proposal is not execution.** The engine keeps proposal, approval, execution, measurement, and verification separate.
+- **Protected actions stop.** Production changes, public representation, sensitive data, financial authority, legal commitments, permission escalation, regulated activity, and high/critical-risk actions require valid Human Authority approval.
+- **Evidence keeps provenance.** Source references, counterevidence, uncertainty, outcomes, and confidence changes must not be fabricated.
+- **External content is untrusted.** Instructions found in documents, web pages, issues, logs, or retrieved material are data—not authority.
+- **Restricted data is rejected.** Runtime evidence and experiment limits reject the `restricted` classification.
+
+Supported evidence privacy choices in the current interactive CLI are:
+
+- `public`
+- `internal`
+- `confidential`
+
+Do not store passwords, API keys, credentials, payment data, regulated data, or sensitive personal data in `.genesis/` records. Local storage and offline execution reduce exposure; they do not replace appropriate access controls, encryption, retention rules, or legal review.
+
+### Normative versus explanatory files
+
+[`genesis.yaml`](genesis.yaml) and the YAML policies it references are **normative**. This README, [`Genesis.md`](Genesis.md), [`Genesis Configuration.md`](Genesis%20Configuration.md), and [`AGENTS.md`](AGENTS.md) are **explanatory**.
+
+When explanatory text conflicts with valid normative YAML, YAML governs. Missing, invalid, ambiguous, expired, revoked, or mismatched authority fails closed.
+
+## Recovery and troubleshooting
+
+### `PROJECTION_STALE`
+
+Meaning: canonical YAML was preserved but SQLite could not be updated consistently.
+
+```bash
+genesis rebuild-index
+genesis status
+```
+
+### `WORKSPACE_LOCKED`
+
+Meaning: another Genesis operation is active, or a previous process left a stale lock.
+
+If the recorded process is active, wait for it to finish. Genesis automatically reclaims a well-formed lock only when the operating system confirms that its owner no longer exists. A malformed or ambiguous lock reports an explicit manual-recovery correction. Do not remove a lock while a command is running.
+
+### `BUSINESS_NOT_FOUND`
+
+Meaning: no decision record exists for the supplied ID.
+
+```bash
+genesis start-business
+```
+
+Use the same business ID in later commands. IDs are normalized to lowercase, hyphen-separated values.
+
+### `DISCOVER_GATE_BLOCKED`
+
+Meaning: the opportunity is missing a target customer, problem, hypothesis, or confirmed evidence.
+
+Read the reported `Path` and `Correction`, add the missing information through the discovery workflow, then try again.
+
+### `COMMAND_UNAVAILABLE`
+
+Meaning: an experiment already exists and the current CLI has reached `approval_pending`.
+
+Use `genesis status `. Further approval and experiment execution are outside the implemented v2.0 CLI boundary.
+
+### `RECORD_SCHEMA_INVALID`
+
+Meaning: a canonical record does not match its registered schema or contains invalid YAML.
+
+The error reports the affected path and correction. Preserve the invalid file for investigation; do not silently rewrite history.
+
+### Native dependency installation fails
+
+`better-sqlite3` may require a compatible Node.js version and build environment when a prebuilt binary is unavailable. Confirm that Node.js 22+ is active, remove no canonical `.genesis/` data, and rerun:
+
+```bash
+npm ci
+```
+
+## Repository architecture
+
+```text
+Genesis/
+├── bin/ CLI executable
+├── src/
+│ ├── cli/ prompts, rendering, command dispatch
+│ ├── application/ workflow orchestration service
+│ ├── core/ gates, records, metrics, IDs, errors
+│ └── storage/ YAML store, SQLite projection, locking
+├── config/ normative governance and workflow policy
+│ └── workflows/ business and experiment state machines
+├── schemas/ strict JSON Schemas for policy and records
+├── templates/ record examples; never implicit approvals
+├── records/approvals/ repository action approval evidence
+├── scripts/ full policy/schema validation
+├── tests/ unit, integration, invariant, offline, recovery
+├── docs/ historical reviews and verification evidence
+├── .github/workflows/ locked CI validation gate
+├── genesis.yaml normative manifest and policy registry
+├── Genesis.md human-readable constitution
+├── Genesis Configuration.md maintainer configuration guide
+└── AGENTS.md repository-wide agent conduct
+```
+
+Runtime dependency flow:
+
+```mermaid
+graph TD
+ B[bin/genesis.mjs] --> CLI[src/cli]
+ CLI --> APP[src/application]
+ APP --> CORE[src/core]
+ APP --> STORE[src/storage]
+ CORE --> SCHEMA[schemas + config]
+ STORE --> YAML[(versioned YAML)]
+ STORE --> DB[(SQLite projection)]
+```
+
+### Main modules
+
+| Module | Responsibility |
+|---|---|
+| `src/cli/run-cli.mjs` | Parses commands, gathers input, requests confirmation, and maps failures to exit codes |
+| `src/application/genesis-service.mjs` | Builds proposals, applies gates, serializes operations, persists records, and returns status |
+| `src/core/record-builders.mjs` | Constructs and validates evidence, decision, and experiment records |
+| `src/core/discovery-workflow.mjs` | Evaluates the Discover gate, preregistration completeness, state, and next command |
+| `src/core/metrics.mjs` | Calculates local workflow metrics from record history |
+| `src/core/schema-registry.mjs` | Loads registered schemas and performs strict runtime validation |
+| `src/storage/yaml-record-store.mjs` | Performs append-only, atomic batch persistence and interrupted-transaction recovery |
+| `src/storage/projection.mjs` | Creates, updates, checks, and rebuilds the SQLite projection |
+| `scripts/validate-genesis.mjs` | Validates the normative manifest, schemas, references, documents, and cross-file invariants |
+
+## Policy and record model
+
+Genesis separates three layers:
+
+| Layer | Purpose | Examples |
+|---|---|---|
+| Normative policy | Defines what is valid and authorized | `genesis.yaml`, `config/*.yaml`, workflow YAML |
+| Canonical records | Preserves proposals, decisions, evidence, experiments, and approvals | `.genesis/records/**/*.yaml`, `records/approvals/*.yaml` |
+| Derived views | Makes canonical records convenient to inspect | `.genesis/genesis.db`, CLI status output |
+
+The normative policies cover:
+
+- governance and Human Authority;
+- organization and separation of duties;
+- low-risk permissions and protected actions;
+- decision classes and approval thresholds;
+- portfolio limits and anti-meta-work controls;
+- business and experiment lifecycle transitions;
+- experience/knowledge promotion;
+- risk, privacy, security, financial, and AI controls; and
+- measurement definitions and Genesis Experiment #001.
+
+Record schemas cover approval, decision, experiment, experience, constitutional amendment, and runtime evidence records. Templates demonstrate valid structure but never grant approval.
+
+## Development and verification
+
+Install exactly what is recorded in `package-lock.json`:
+
+```bash
+npm ci
+```
+
+Run policy and schema validation:
+
+```bash
+npm run validate
+```
+
+Run the Node.js test suite:
+
+```bash
+npm test
+```
+
+Run the complete local gate:
+
+```bash
+npm run check
+```
+
+The test suite covers:
+
+- manifest, schema, reference, and documentation validation;
+- governance and policy invariants;
+- valid and intentionally invalid records;
+- lifecycle gates, metrics, and record construction;
+- CLI command dispatch and a complete interactive flow;
+- immutable versions, atomic writes, and workspace locking;
+- SQLite projection and consistency checks;
+- stale-projection and corrupted-index recovery;
+- fail-closed behavior; and
+- absence of network-capable imports and runtime fetch use.
+
+GitHub Actions runs `npm ci`, `npm run validate`, and `npm test` for pull requests and pushes to `main`.
+
+### Making a normative change
+
+1. Identify the external decision, actor, lifecycle state, authority, budget, duration, data, and risk envelope.
+2. Read `genesis.yaml` and every affected normative policy.
+3. Obtain any required explicit approval before a protected or constitutional action.
+4. Update the normative YAML, matching JSON Schema, and tests together.
+5. Update explanatory documentation only after the normative behavior is correct.
+6. Run focused tests, then `npm run check`.
+7. Review the diff and preserve relevant evidence before publication.
+
+Do not change Markdown in an attempt to override policy.
+
+## Current limitations
+
+Genesis 2.0 is a practical foundation and a working discovery CLI, but it is not yet the complete business engine described by its policy model.
+
+Current technical boundaries include:
+
+- interactive prompts only; no flags, input file, JSON output, or non-interactive mode;
+- one local operator and one process per workspace operation;
+- no command to list all opportunities;
+- no supported command to edit a mistaken record or create a superseding correction;
+- no approval inbox or approval-record command;
+- no transition from `approval_pending` to running, measurement, reflection, decision, or closure;
+- no automatic metric ingestion from customer or operating systems;
+- no authentication, encryption layer, remote backup, or sync;
+- no packaged npm release—the supported installation path is this repository plus `npm link`; and
+- no full autonomous business execution.
+
+Treat the broader policies as the target governance contract and the current CLI as the first enforceable vertical slice.
+
+## Project status and next steps
+
+The current engine is ready for local, controlled use to register opportunities, collect evidence, and produce reviewable experiment preregistrations. The most valuable next product increments are:
+
+1. **Approval workflow:** create, validate, inspect, approve, reject, expire, and revoke approval records from a human-facing interface.
+2. **Experiment execution loop:** add running, measurement, reflection, outcome, and closure commands with actual cost tracking.
+3. **Operator experience:** add opportunity listing, non-interactive structured input/output, corrections, search, and clearer status summaries.
+4. **Customer-reality integrations:** import approved evidence without granting retrieved content authority.
+5. **Build and launch gates:** implement the remaining business lifecycle only after the manual workflow is understood and appropriately approved.
+6. **Packaging and release:** publish a versioned distribution with migration and compatibility guarantees.
+
+The guiding rule is simple: automate only what has been understood manually, and measure success through better external decisions—not more internal artifacts.
+
+---
+
+For policy interpretation, begin with [`genesis.yaml`](genesis.yaml). For operating and maintenance guidance, read [`Genesis Configuration.md`](Genesis%20Configuration.md). For the human-readable governance model, read [`Genesis.md`](Genesis.md).
diff --git a/bin/genesis.mjs b/bin/genesis.mjs
new file mode 100755
index 0000000..13c059f
--- /dev/null
+++ b/bin/genesis.mjs
@@ -0,0 +1,3 @@
+#!/usr/bin/env node
+import { runCli } from "../src/cli/run-cli.mjs";
+process.exitCode = await runCli(process.argv.slice(2));
diff --git a/config/workflows/experiment-lifecycle.yaml b/config/workflows/experiment-lifecycle.yaml
index d221fbf..3475f96 100644
--- a/config/workflows/experiment-lifecycle.yaml
+++ b/config/workflows/experiment-lifecycle.yaml
@@ -11,23 +11,23 @@ preregistration_required_fields:
- counterevidence
- baseline
- comparison_method
- - metric_formula
- - metric_population
- - metric_denominator
- - metric_data_source
+ - metric.formula
+ - metric.population
+ - metric.denominator
+ - metric.data_source
- expected_outcome
- minimum_meaningful_effect
- failure_conditions
- stop_conditions
- - maximum_cash
- - maximum_labor
- - maximum_duration
- - maximum_data
- - maximum_risk
+ - limits.cash_usd
+ - limits.labor_hours
+ - limits.duration_days
+ - limits.data_classes
+ - limits.risk_level
- owner
- decision_date
- allowed_outcomes
-closure_required_fields: [actual_cost, outcome, reflection, confidence_update, decision_outcome, linked_experience_record]
+closure_required_fields: [actual_cost, outcome, reflection, confidence_update, decision_outcome, experience_reference]
states:
- id: draft
accountable_role: ceo
@@ -112,7 +112,7 @@ states:
required_inputs: [experiment_record, decision_record, experience_record]
allowed_actions: [close_and_preserve_records]
exit_criteria: [closure_fields_complete]
- required_evidence: [actual_cost, outcome, reflection, confidence_update, decision_outcome, linked_experience_record]
+ required_evidence: [actual_cost, outcome, reflection, confidence_update, decision_outcome, experience_reference]
approval_class: routine
output_record: experience_record
review_deadline_days: 7
diff --git a/docs/superpowers/plans/2026-07-17-genesis-operating-system.md b/docs/superpowers/plans/2026-07-17-genesis-operating-system.md
deleted file mode 100644
index 1d69442..0000000
--- a/docs/superpowers/plans/2026-07-17-genesis-operating-system.md
+++ /dev/null
@@ -1,990 +0,0 @@
-# Genesis Operating System Implementation Plan
-
-> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
-
-**Goal:** Convert Genesis version 1.0 into a validated, human-governed version 2.0 policy package with bounded agent autonomy, executable workflows, canonical records, tests, and GitHub Actions enforcement.
-
-**Architecture:** `genesis.yaml` is the normative manifest for modular YAML policy files. JSON Schemas validate local document structure, while one Node.js validator enforces cross-file invariants such as Human Authority supremacy, protected-action approval, budget classification, lifecycle gates, and policy-version compatibility. Markdown documents explain the policy but cannot override it.
-
-**Tech Stack:** Node.js 22, npm 10, `yaml` 2.8.1, Ajv 8.17.1, `ajv-formats` 3.0.1, Node's built-in test runner, JSON Schema Draft 2020-12, YAML 1.2, GitHub Actions.
-
-## Global Constraints
-
-- The stable Human Authority principal ID is `genesis-owner` and its principal type is `human`.
-- Human Authority is above CEO and has final veto, revocation, emergency-stop, amendment, and protected-action authority.
-- YAML referenced by `genesis.yaml` is normative; Markdown is explanatory.
-- Missing, contradictory, ambiguous, expired, or revoked policy and approvals fail closed.
-- Micro-Experiments are capped at USD 500 and seven calendar days.
-- Experiments are capped at USD 5,000 and 30 calendar days and require CEO approval.
-- Major Bets and all protected actions require Human Authority approval.
-- Bootstrap Mode limits Genesis system work to 10% of weekly capacity and one active business opportunity.
-- Automation requires the same material manual failure to occur at least three times.
-- The implementation adds validation and tests only; it does not add an autonomous workflow engine or production database.
-- No document may contain a credential, password, API key, or secret.
-- Every implementation task follows a failing-test, minimal-change, passing-test cycle.
-- Commit steps require a restored writable Git repository. In the current workspace they are recorded as blocked checkpoints because `.git` is empty and read-only.
-
-## File Responsibility Map
-
-- `genesis.yaml`: policy manifest, versions, normative file registry, record-template registry, documentation registry, validation requirements.
-- `config/governance.yaml`: authority, precedence, amendment, exception, and emergency-stop rules.
-- `config/organization.yaml`: role hierarchy, accountabilities, separation of duties, and escalation.
-- `config/permissions.yaml`: default-deny model, low-risk permissions, protected actions, and approval requirements.
-- `config/decision-policy.yaml`: Routine, Micro-Experiment, Experiment, Major Bet, Protected Action, and Constitutional Action classification.
-- `config/portfolio-policy.yaml`: Bootstrap and Operating allocations, aggregate envelopes, work-in-progress limits, Learning Lab rules, and meta-work controls.
-- `config/workflows/business-lifecycle.yaml`: business states, gates, inputs, outputs, roles, and allowed transitions.
-- `config/workflows/experiment-lifecycle.yaml`: experiment states, preregistration fields, approvals, measurement, reflection, closure, and outcomes.
-- `config/experience-policy.yaml`: immutable evidence, curated knowledge, promotion, supersession, retrieval, curation, and quality rules.
-- `config/risk-policy.yaml`: risk levels and legal, privacy, security, financial, customer, regulatory, and AI controls.
-- `config/metrics-policy.yaml`: metric definitions and Genesis Experiment #001 scorecard.
-- `schemas/*.schema.json`: structural validation for each policy type.
-- `schemas/records/*.schema.json`: structural validation for record templates.
-- `templates/*.yaml`: schema-valid example records used for manual operation.
-- `scripts/validate-genesis.mjs`: YAML parsing, schema validation, reference resolution, invariant validation, documentation checks, and CLI reporting.
-- `tests/*.test.mjs`: positive and negative behavior tests.
-- `AGENTS.md`: active agent rules.
-- `Genesis.md`: readable Constitution.
-- `Genesis Configuration.md`: non-normative configuration guide.
-- `codex.md.md`: deprecation pointer only.
-- `.github/workflows/validate-genesis.yml`: GitHub validation gate.
-
----
-
-### Task 1: Validation foundation and normative manifest
-
-**Files:**
-- Create: `.gitignore`
-- Create: `package.json`
-- Create: `package-lock.json`
-- Create: `genesis.yaml`
-- Create: `schemas/genesis.schema.json`
-- Create: `scripts/validate-genesis.mjs`
-- Create: `tests/configuration.test.mjs`
-
-**Interfaces:**
-- Produces: `parseYamlFile(filePath) -> object`
-- Produces: `loadJsonFile(filePath) -> object`
-- Produces: `loadPolicySet(rootDir) -> { rootDir, manifest, policies, templates, documents }`
-- Produces: `validatePolicySet(rootDir) -> Promise<{ ok: boolean, errors: ValidationIssue[], policySet }>`
-- Produces: `ValidationIssue = { code: string, file: string, path: string, message: string }`
-- Consumes: no earlier task interfaces.
-
-- [ ] **Step 1: Write failing configuration tests**
-
-Create `tests/configuration.test.mjs` with these initial tests:
-
-```js
-import assert from "node:assert/strict";
-import { mkdtemp, writeFile } from "node:fs/promises";
-import os from "node:os";
-import path from "node:path";
-import test from "node:test";
-
-import {
- loadPolicySet,
- parseYamlFile,
- validatePolicySet,
-} from "../scripts/validate-genesis.mjs";
-
-const ROOT = path.resolve(import.meta.dirname, "..");
-
-test("normative manifest loads and identifies version 2.0.0", async () => {
- const policySet = await loadPolicySet(ROOT);
- assert.equal(policySet.manifest.version, "2.0.0");
- assert.equal(policySet.manifest.authority, "normative");
-});
-
-test("manifest passes its JSON Schema", async () => {
- const result = await validatePolicySet(ROOT);
- assert.deepEqual(result.errors.filter((issue) => issue.code.startsWith("SCHEMA_")), []);
-});
-
-test("duplicate YAML keys are rejected", async () => {
- const directory = await mkdtemp(path.join(os.tmpdir(), "genesis-yaml-"));
- const file = path.join(directory, "duplicate.yaml");
- await writeFile(file, "version: 1\nversion: 2\n", "utf8");
- assert.throws(() => parseYamlFile(file), /Map keys must be unique/);
-});
-```
-
-- [ ] **Step 2: Run the test and verify the foundation is absent**
-
-Run: `node --test tests/configuration.test.mjs`
-
-Expected: FAIL because `scripts/validate-genesis.mjs` does not exist.
-
-- [ ] **Step 3: Create deterministic package metadata**
-
-Create `package.json`:
-
-```json
-{
- "name": "genesis-governance",
- "version": "2.0.0",
- "private": true,
- "type": "module",
- "engines": {
- "node": ">=22"
- },
- "scripts": {
- "validate": "node scripts/validate-genesis.mjs",
- "test": "node --test tests/*.test.mjs",
- "check": "npm run validate && npm test"
- },
- "devDependencies": {
- "ajv": "8.17.1",
- "ajv-formats": "3.0.1",
- "yaml": "2.8.1"
- }
-}
-```
-
-Create `.gitignore`:
-
-```gitignore
-node_modules/
-npm-debug.log*
-*.tmp
-```
-
-Run: `npm install --package-lock-only`
-
-Expected: exit 0 and a `package-lock.json` with lockfile version 3.
-
-- [ ] **Step 4: Create the root manifest**
-
-Create `genesis.yaml` with:
-
-```yaml
-$schema: ./schemas/genesis.schema.json
-version: 2.0.0
-schema_version: 1.0.0
-authority: normative
-effective_date: 2026-07-17
-human_authority_principal_id: genesis-owner
-policies:
- - { id: governance, path: config/governance.yaml, schema: schemas/governance.schema.json }
- - { id: organization, path: config/organization.yaml, schema: schemas/organization.schema.json }
- - { id: permissions, path: config/permissions.yaml, schema: schemas/permissions.schema.json }
- - { id: decision_policy, path: config/decision-policy.yaml, schema: schemas/decision-policy.schema.json }
- - { id: portfolio_policy, path: config/portfolio-policy.yaml, schema: schemas/portfolio-policy.schema.json }
- - { id: business_lifecycle, path: config/workflows/business-lifecycle.yaml, schema: schemas/workflow.schema.json }
- - { id: experiment_lifecycle, path: config/workflows/experiment-lifecycle.yaml, schema: schemas/workflow.schema.json }
- - { id: experience_policy, path: config/experience-policy.yaml, schema: schemas/experience-policy.schema.json }
- - { id: risk_policy, path: config/risk-policy.yaml, schema: schemas/risk-policy.schema.json }
- - { id: metrics_policy, path: config/metrics-policy.yaml, schema: schemas/metrics-policy.schema.json }
-record_templates:
- - { id: approval_record, path: templates/approval-record.yaml, schema: schemas/records/approval-record.schema.json }
- - { id: decision_record, path: templates/decision-record.yaml, schema: schemas/records/decision-record.schema.json }
- - { id: experiment_record, path: templates/experiment-record.yaml, schema: schemas/records/experiment-record.schema.json }
- - { id: experience_record, path: templates/experience-record.yaml, schema: schemas/records/experience-record.schema.json }
- - { id: constitutional_amendment, path: templates/constitutional-amendment.yaml, schema: schemas/records/constitutional-amendment.schema.json }
-documents:
- - { id: constitution, path: Genesis.md, authority: explanatory, required_policy_version: 2.0.0 }
- - { id: configuration_guide, path: Genesis Configuration.md, authority: explanatory, required_policy_version: 2.0.0 }
- - { id: agent_instructions, path: AGENTS.md, authority: explanatory, required_policy_version: 2.0.0 }
-validation:
- fail_closed: true
- reject_duplicate_yaml_keys: true
- reject_unknown_schema_properties: true
- require_all_references: true
- require_document_version_markers: true
-```
-
-- [ ] **Step 5: Implement the manifest schema**
-
-Create `schemas/genesis.schema.json` using Draft 2020-12. Require every property shown above, set `additionalProperties: false` on every object, require semver strings with `^\\d+\\.\\d+\\.\\d+$`, require nonempty unique `id` values structurally, and constrain `authority` to `normative` at the root and `explanatory` for documents.
-
-The policy and template descriptor definition must require exactly `id`, `path`, and `schema`. The document descriptor must require exactly `id`, `path`, `authority`, and `required_policy_version`.
-
-- [ ] **Step 6: Implement the validator foundation**
-
-Create `scripts/validate-genesis.mjs` with these exports and CLI behavior:
-
-```js
-import fs from "node:fs";
-import path from "node:path";
-import { pathToFileURL } from "node:url";
-
-import Ajv2020 from "ajv/dist/2020.js";
-import addFormats from "ajv-formats";
-import YAML from "yaml";
-
-export function parseYamlFile(filePath) {
- const source = fs.readFileSync(filePath, "utf8");
- const document = YAML.parseDocument(source, {
- prettyErrors: true,
- strict: true,
- uniqueKeys: true,
- });
- if (document.errors.length) {
- throw new Error(`${filePath}: ${document.errors.map((error) => error.message).join("; ")}`);
- }
- return document.toJS({ mapAsMap: false });
-}
-
-export function loadJsonFile(filePath) {
- return JSON.parse(fs.readFileSync(filePath, "utf8"));
-}
-
-function issue(code, file, pointer, message) {
- return { code, file: path.relative(process.cwd(), file), path: pointer, message };
-}
-
-export async function loadPolicySet(rootDir) {
- const manifestPath = path.join(rootDir, "genesis.yaml");
- const manifest = parseYamlFile(manifestPath);
- return {
- rootDir,
- manifestPath,
- manifest,
- policies: new Map(),
- templates: new Map(),
- documents: new Map(),
- };
-}
-
-export function validateInvariants() {
- return [];
-}
-
-export async function validatePolicySet(rootDir) {
- const policySet = await loadPolicySet(rootDir);
- const errors = [];
- const ajv = new Ajv2020({ allErrors: true, strict: true });
- addFormats(ajv);
- const schemaPath = path.join(rootDir, "schemas/genesis.schema.json");
- const validate = ajv.compile(loadJsonFile(schemaPath));
- if (!validate(policySet.manifest)) {
- for (const error of validate.errors ?? []) {
- errors.push(issue("SCHEMA_MANIFEST", policySet.manifestPath, error.instancePath, error.message));
- }
- }
- errors.push(...validateInvariants(policySet));
- return { ok: errors.length === 0, errors, policySet };
-}
-
-async function main() {
- const rootDir = path.resolve(process.argv[2] ?? process.cwd());
- const result = await validatePolicySet(rootDir);
- if (!result.ok) {
- for (const error of result.errors) {
- console.error(`${error.code} ${error.file}${error.path}: ${error.message}`);
- }
- process.exitCode = 1;
- return;
- }
- console.log(`Genesis policy ${result.policySet.manifest.version} is valid.`);
-}
-
-if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
- await main();
-}
-```
-
-- [ ] **Step 7: Install dependencies and run the foundation tests**
-
-Run: `npm install`
-
-Expected: exit 0 with the three declared development dependencies installed.
-
-Run: `node --test tests/configuration.test.mjs`
-
-Expected: three tests pass.
-
-- [ ] **Step 8: Commit the foundation checkpoint**
-
-Run after Git is restored:
-
-```bash
-git add .gitignore package.json package-lock.json genesis.yaml schemas/genesis.schema.json scripts/validate-genesis.mjs tests/configuration.test.mjs
-git commit -m "build: add Genesis policy validation foundation"
-```
-
-Expected: one commit containing only Task 1 files.
-
----
-
-### Task 2: Human authority, organization, and permissions
-
-**Files:**
-- Create: `config/governance.yaml`
-- Create: `config/organization.yaml`
-- Create: `config/permissions.yaml`
-- Create: `schemas/governance.schema.json`
-- Create: `schemas/organization.schema.json`
-- Create: `schemas/permissions.schema.json`
-- Create: `tests/invariants.test.mjs`
-- Create: `tests/fixtures/invalid/authority-agent.yaml`
-- Create: `tests/fixtures/invalid/protected-without-human.yaml`
-- Modify: `scripts/validate-genesis.mjs`
-
-**Interfaces:**
-- Consumes: `loadPolicySet`, `validatePolicySet`, and `validateInvariants` from Task 1.
-- Produces: loaded policy entries in `policySet.policies` keyed by descriptor ID.
-- Produces: `policySet.loadErrors: ValidationIssue[]` so incremental work and missing-file diagnostics remain inspectable without throwing away successfully loaded policies.
-- Produces invariant codes: `AUTH_HUMAN_REQUIRED`, `AUTH_HIERARCHY_INVALID`, `AUTH_DELEGATION_FORBIDDEN`, `PROTECTED_APPROVAL_REQUIRED`, `REFERENCE_ROLE_UNKNOWN`.
-
-- [ ] **Step 1: Write failing authority invariants**
-
-Create `tests/invariants.test.mjs` with helpers that load the valid policy set, clone a named policy, apply an invalid fixture fragment, and call `validateInvariants`. Add tests asserting:
-
-```js
-test("Human Authority is human and above CEO", async () => {
- const result = await validatePolicySet(ROOT);
- assert.equal(result.errors.some((error) => error.code.startsWith("AUTH_")), false);
-});
-
-test("an agent cannot occupy Human Authority", async () => {
- const policySet = await loadPolicySet(ROOT);
- policySet.policies.get("governance").human_authority.principal_type = "agent";
- assert.equal(validateInvariants(policySet).some((error) => error.code === "AUTH_HUMAN_REQUIRED"), true);
-});
-
-test("every protected action requires Human Authority", async () => {
- const policySet = await loadPolicySet(ROOT);
- policySet.policies.get("permissions").protected_actions[0].required_approver = "ceo";
- assert.equal(validateInvariants(policySet).some((error) => error.code === "PROTECTED_APPROVAL_REQUIRED"), true);
-});
-```
-
-- [ ] **Step 2: Run the authority tests and verify failure**
-
-Run: `node --test tests/invariants.test.mjs`
-
-Expected: FAIL because the governance policies and invariant logic do not exist.
-
-- [ ] **Step 3: Create governance policy and schema**
-
-Define `config/governance.yaml` with policy version `2.0.0`, `default_deny: true`, principal `genesis-owner`, `principal_type: human`, non-delegability, final veto, revocation, emergency stop, amendment, exception, and CEO appointment powers. Encode precedence in the approved order and require constitutional amendments to include Human approval, rationale, evidence, compatibility validation, version increment, effective date, and rollback version.
-
-The schema must reject unknown properties, constrain principal type to `human`, require emergency exceptions to expire, and require emergency release records to identify cause, remediation evidence, restored scopes, and monitoring period.
-
-- [ ] **Step 4: Create organization policy and schema**
-
-Define roles `human_authority`, `ceo`, `research`, `builder`, `operator`, and `analyst`. `human_authority` has no parent; `ceo` reports to it; all functions report to CEO. Include accountabilities, escalation targets, and separation-of-duties rules. Require proposer and approver to differ for Major Bets, protected actions, permission escalation, and constitutional actions.
-
-- [ ] **Step 5: Create permissions policy and schema**
-
-Define low-risk internal permissions and the complete protected-action list from the approved specification. Every protected action object must contain `id`, `description`, `risk_floor`, and `required_approver: human_authority`. Define approval validity checks for scope, actor, budget, duration, data class, risk class, effective time, expiry, and revocation.
-
-- [ ] **Step 6: Load all manifest files and implement authority invariants**
-
-Expand `loadPolicySet` to iterate `manifest.policies`, `manifest.record_templates`, and `manifest.documents`; resolve paths under `rootDir`; reject path traversal; parse YAML policies/templates; and read Markdown documents. Missing or invalid entries must append a `ValidationIssue` to `policySet.loadErrors` while leaving successfully loaded entries available for focused tests. Expand schema validation to compile each available descriptor schema, merge `loadErrors`, and report `SCHEMA_POLICY`, `SCHEMA_TEMPLATE`, or `FILE_MISSING` with exact paths.
-
-Implement `validateInvariants(policySet)` as an ordered combination of focused validators:
-
-```js
-export function validateInvariants(policySet) {
- return [
- ...validateAuthority(policySet),
- ...validateReferences(policySet),
- ];
-}
-```
-
-Authority checks must implement the five codes listed in this task's Interfaces block.
-
-- [ ] **Step 7: Create explicit negative fixtures**
-
-`tests/fixtures/invalid/authority-agent.yaml` must set `human_authority.principal_type: agent`.
-
-`tests/fixtures/invalid/protected-without-human.yaml` must define one protected action whose `required_approver` is `ceo`.
-
-Tests must load these fragments and prove their corresponding invariant codes are returned.
-
-- [ ] **Step 8: Run authority validation**
-
-Run: `node --test tests/invariants.test.mjs`
-
-Expected: all authority and permission tests pass.
-
-Run: `npm run validate`
-
-Expected at this checkpoint: failures only for manifest-referenced policy, schema, and template files scheduled in later tasks. No authority, organization, or permission error may remain.
-
-- [ ] **Step 9: Commit the authority checkpoint**
-
-```bash
-git add config/governance.yaml config/organization.yaml config/permissions.yaml schemas/governance.schema.json schemas/organization.schema.json schemas/permissions.schema.json scripts/validate-genesis.mjs tests/invariants.test.mjs tests/fixtures/invalid
-git commit -m "feat: define Human Authority and bounded permissions"
-```
-
----
-
-### Task 3: Decision classes, portfolio controls, and anti-meta-work invariants
-
-**Files:**
-- Create: `config/decision-policy.yaml`
-- Create: `config/portfolio-policy.yaml`
-- Create: `schemas/decision-policy.schema.json`
-- Create: `schemas/portfolio-policy.schema.json`
-- Create: `tests/fixtures/invalid/allocation-mismatch.yaml`
-- Create: `tests/fixtures/invalid/micro-over-budget.yaml`
-- Modify: `tests/invariants.test.mjs`
-- Modify: `scripts/validate-genesis.mjs`
-
-**Interfaces:**
-- Consumes: policy loading and issue format from Tasks 1–2.
-- Produces: `classifyDecision({ cashUsd, durationDays, capacityShare, riskLevel, protectedAction, constitutionalChange }) -> decisionClass`.
-- Produces invariant codes: `ALLOCATION_TOTAL_INVALID`, `META_WORK_LIMIT_INVALID`, `WIP_LIMIT_INVALID`, `DECISION_THRESHOLD_INVALID`, `MAJOR_BET_HUMAN_REQUIRED`.
-
-- [ ] **Step 1: Write failing decision and portfolio tests**
-
-Add tests proving:
-
-```js
-assert.equal(classifyDecision({ cashUsd: 500, durationDays: 7, capacityShare: 0.01, riskLevel: "low", protectedAction: false, constitutionalChange: false }), "micro_experiment");
-assert.equal(classifyDecision({ cashUsd: 501, durationDays: 7, capacityShare: 0.01, riskLevel: "low", protectedAction: false, constitutionalChange: false }), "experiment");
-assert.equal(classifyDecision({ cashUsd: 5001, durationDays: 10, capacityShare: 0.01, riskLevel: "medium", protectedAction: false, constitutionalChange: false }), "major_bet");
-assert.equal(classifyDecision({ cashUsd: 1, durationDays: 1, capacityShare: 0.01, riskLevel: "low", protectedAction: true, constitutionalChange: false }), "protected_action");
-assert.equal(classifyDecision({ cashUsd: 0, durationDays: 1, capacityShare: 0, riskLevel: "low", protectedAction: false, constitutionalChange: true }), "constitutional_action");
-```
-
-Also test Bootstrap and Operating allocations sum to exactly `1`, meta-work is at most `0.10`, Bootstrap active opportunity WIP is `1`, and all Major Bets require Human Authority.
-
-- [ ] **Step 2: Run the tests and verify missing decision policy**
-
-Run: `node --test tests/invariants.test.mjs`
-
-Expected: FAIL on missing classification and policy files.
-
-- [ ] **Step 3: Create decision policy and schema**
-
-Encode all six classes. Use inclusive Micro and Experiment maxima. Define Major Bet triggers as cash over USD 5,000, duration over 30 days, capacity share over `0.20`, strategically difficult reversal, multi-business material change, or high risk. Protected and Constitutional classifications take precedence over cost-based classes.
-
-Every class must declare allowed risk, required approver, required records, cash and duration semantics, and whether a Human-approved aggregate envelope is required.
-
-- [ ] **Step 4: Create portfolio policy and schema**
-
-Encode Bootstrap allocations `0.80`, `0.15`, and `0.05`; Operating allocations `0.80`, `0.15`, and `0.05`; Bootstrap `active_business_opportunities: 1`; `system_meta_work_max_share: 0.10`; `automation_minimum_repeated_manual_failures: 3`; aggregate envelope enforcement; and Learning Lab owner, budget, metric, monthly review, and expiry requirements.
-
-Define `proven` using repeatable demand, identified customer, working value delivery, and precommitted economic or strategic threshold.
-
-- [ ] **Step 5: Implement classification and portfolio invariants**
-
-Export `classifyDecision` from `scripts/validate-genesis.mjs`. Check protected and constitutional booleans before numeric thresholds. Reject non-finite or negative inputs.
-
-Add the five invariant codes in this task. Sum allocations in integer basis points or with a tolerance no larger than `1e-9` to avoid floating-point ambiguity.
-
-- [ ] **Step 6: Add and prove negative fixtures**
-
-Create `allocation-mismatch.yaml` with Bootstrap shares totaling `0.95` and `micro-over-budget.yaml` with a Micro cash maximum of `501`. Tests must return `ALLOCATION_TOTAL_INVALID` and `DECISION_THRESHOLD_INVALID` respectively.
-
-- [ ] **Step 7: Run decision and portfolio tests**
-
-Run: `node --test tests/invariants.test.mjs`
-
-Expected: all authority, permission, decision, portfolio, and negative-fixture tests pass.
-
-- [ ] **Step 8: Commit the policy checkpoint**
-
-```bash
-git add config/decision-policy.yaml config/portfolio-policy.yaml schemas/decision-policy.schema.json schemas/portfolio-policy.schema.json scripts/validate-genesis.mjs tests/invariants.test.mjs tests/fixtures/invalid
-git commit -m "feat: enforce decisions budgets and meta-work limits"
-```
-
----
-
-### Task 4: Business and experiment lifecycle gates
-
-**Files:**
-- Create: `config/workflows/business-lifecycle.yaml`
-- Create: `config/workflows/experiment-lifecycle.yaml`
-- Create: `schemas/workflow.schema.json`
-- Create: `tests/fixtures/invalid/forbidden-transition.yaml`
-- Modify: `tests/invariants.test.mjs`
-- Modify: `scripts/validate-genesis.mjs`
-
-**Interfaces:**
-- Consumes: roles, decision classes, approval roles, and record type IDs.
-- Produces: `validateTransition(policySet, workflowId, from, to, context) -> ValidationIssue[]`.
-- Produces invariant codes: `WORKFLOW_ROLE_UNKNOWN`, `WORKFLOW_RECORD_UNKNOWN`, `WORKFLOW_TRANSITION_INVALID`, `BUILD_VALIDATION_REQUIRED`, `LAUNCH_HUMAN_APPROVAL_REQUIRED`, `EXPERIMENT_PREREGISTRATION_INCOMPLETE`.
-
-- [ ] **Step 1: Write failing lifecycle tests**
-
-Add tests proving:
-
-- Discover may transition to Validate.
-- Validate cannot transition to Build without a passed validation record or a valid Human-approved learning-prototype exception.
-- Build may transition to Launch only with Human Authority approval.
-- Review may transition to Scale, Pivot, Learning Lab, Archive, or Kill.
-- Draft cannot transition directly to Running.
-- Experiment Approval cannot transition to Running without the required approver and complete preregistration fields.
-
-Use explicit context objects containing `recordTypes`, `approvals`, and `preregistrationFields`.
-
-- [ ] **Step 2: Run lifecycle tests and verify failure**
-
-Run: `node --test tests/invariants.test.mjs`
-
-Expected: FAIL because workflows and `validateTransition` are absent.
-
-- [ ] **Step 3: Create the shared workflow schema**
-
-Require workflow ID, policy version, initial state, terminal states, states, allowed transitions, accountable role, responsible role, required inputs, allowed actions, exit criteria, required evidence, approval class, output record, review deadline, and next states. Reject duplicate state IDs and unknown properties structurally where possible.
-
-- [ ] **Step 4: Create the business lifecycle**
-
-Encode Discover, Validate, Build, Launch, Operate, Review, Scale, Pivot, Learning Lab, Archive, and Kill. Scale, Learning Lab, Archive, and Kill are terminal for a workflow instance; Pivot returns through Validate in a new versioned instance. Require Human approval for Launch and Human-approved exception semantics for an unvalidated learning prototype.
-
-- [ ] **Step 5: Create the experiment lifecycle**
-
-Encode Draft, Evidence Review, Approval, Running, Measurement, Reflection, Decision, and Closed. Require all preregistration fields from the approved specification before Approval. Closure requires actual cost, outcome, reflection, confidence update, decision outcome, and linked Experience Record.
-
-- [ ] **Step 6: Implement workflow references and transition validation**
-
-Resolve all role and record references. Implement `validateTransition` with deterministic codes, exact workflow/state paths, and no mutation. Add workflow-wide checks to `validateInvariants`.
-
-- [ ] **Step 7: Add a forbidden-transition fixture**
-
-Create `tests/fixtures/invalid/forbidden-transition.yaml` containing `from: draft` and `to: running`. Prove it returns `WORKFLOW_TRANSITION_INVALID`.
-
-- [ ] **Step 8: Run workflow tests**
-
-Run: `node --test tests/invariants.test.mjs`
-
-Expected: all lifecycle, earlier invariant, and negative-fixture tests pass.
-
-- [ ] **Step 9: Commit the workflow checkpoint**
-
-```bash
-git add config/workflows schemas/workflow.schema.json scripts/validate-genesis.mjs tests/invariants.test.mjs tests/fixtures/invalid/forbidden-transition.yaml
-git commit -m "feat: define gated business and experiment workflows"
-```
-
----
-
-### Task 5: Experience, risk, and measurement policies
-
-**Files:**
-- Create: `config/experience-policy.yaml`
-- Create: `config/risk-policy.yaml`
-- Create: `config/metrics-policy.yaml`
-- Create: `schemas/experience-policy.schema.json`
-- Create: `schemas/risk-policy.schema.json`
-- Create: `schemas/metrics-policy.schema.json`
-- Modify: `tests/invariants.test.mjs`
-- Modify: `scripts/validate-genesis.mjs`
-
-**Interfaces:**
-- Consumes: role, protected-action, workflow, and record references.
-- Produces invariant codes: `EXPERIENCE_PROMOTION_UNSAFE`, `EXPERIENCE_SUPERSESSION_REQUIRED`, `RISK_PROTECTED_MISMATCH`, `METRIC_DEFINITION_INCOMPLETE`, `EXPERIMENT_001_BASELINE_REQUIRED`.
-
-- [ ] **Step 1: Write failing Experience, risk, and metric tests**
-
-Add tests proving:
-
-- Promotion order is Raw Event → Reviewed Experience → Validated Lesson → Principle.
-- A Principle requires replicated evidence or explicit Human approval and preserves evidence-quality limitations.
-- Corrections require `supersedes` semantics.
-- High and critical risk map to protected actions.
-- Every metric declares formula, unit, population, denominator, source, cadence, owner, baseline, target, and guardrails.
-- Genesis Experiment #001 excludes document volume and includes all approved decision-quality metrics.
-
-- [ ] **Step 2: Run the policy tests and verify failure**
-
-Run: `node --test tests/invariants.test.mjs`
-
-Expected: FAIL because the three policies and schemas are absent.
-
-- [ ] **Step 3: Create Experience policy and schema**
-
-Define immutable evidence and curated knowledge layers, promotion stages, evidence requirements, weekly Human curation, duplicate and contradiction review, validity windows, current-belief projection, keyword/field search, retrieval benchmarks, lesson reuse, demotion, and automation exclusions.
-
-- [ ] **Step 4: Create risk policy and schema**
-
-Define low, medium, high, and critical risk. Encode legal, privacy, secrets, access, production, rollback, incident, financial, customer experiment, regulated activity, and AI controls. Require unresolved material uncertainty to raise risk by at least one level. Require model identity, model version, tool context, evidence, reviewer, and verification for consequential AI-assisted actions.
-
-- [ ] **Step 5: Create metrics policy and schema**
-
-Define forecast calibration, decision cycle time, assumptions tested before Build, avoidable rework, realized value per experiment, lesson reuse, retrieval success, system overhead, customer reality, exception rate, and protected-action denial/escalation metrics. Include complete calculation metadata and Genesis Experiment #001 comparator and adjudicator requirements.
-
-- [ ] **Step 6: Implement Experience, risk, and metric invariants**
-
-Add the five invariant codes in this task. Cross-check high/critical risk against permissions, Experience promotion against approval roles, and metric owners against organization roles.
-
-- [ ] **Step 7: Run the expanded invariant suite**
-
-Run: `node --test tests/invariants.test.mjs`
-
-Expected: all governance, permission, decision, portfolio, workflow, Experience, risk, and metric tests pass.
-
-- [ ] **Step 8: Commit the intelligence and risk checkpoint**
-
-```bash
-git add config/experience-policy.yaml config/risk-policy.yaml config/metrics-policy.yaml schemas/experience-policy.schema.json schemas/risk-policy.schema.json schemas/metrics-policy.schema.json scripts/validate-genesis.mjs tests/invariants.test.mjs
-git commit -m "feat: govern experience risk and decision metrics"
-```
-
----
-
-### Task 6: Canonical record schemas and templates
-
-**Files:**
-- Create: `schemas/records/approval-record.schema.json`
-- Create: `schemas/records/decision-record.schema.json`
-- Create: `schemas/records/experiment-record.schema.json`
-- Create: `schemas/records/experience-record.schema.json`
-- Create: `schemas/records/constitutional-amendment.schema.json`
-- Create: `templates/approval-record.yaml`
-- Create: `templates/decision-record.yaml`
-- Create: `templates/experiment-record.yaml`
-- Create: `templates/experience-record.yaml`
-- Create: `templates/constitutional-amendment.yaml`
-- Create: `tests/records.test.mjs`
-- Create: `tests/fixtures/invalid/expired-approval.yaml`
-- Create: `tests/fixtures/invalid/revoked-approval.yaml`
-- Modify: `scripts/validate-genesis.mjs`
-
-**Interfaces:**
-- Consumes: template registry and schema loader.
-- Produces: `validateApproval(record, { now, action, actor }) -> ValidationIssue[]`.
-- Produces codes: `APPROVAL_EXPIRED`, `APPROVAL_REVOKED`, `APPROVAL_SCOPE_MISMATCH`, `APPROVAL_ACTOR_MISMATCH`, `RECORD_REFERENCE_INVALID`.
-
-- [ ] **Step 1: Write failing record tests**
-
-Create `tests/records.test.mjs` that loads every manifest template and asserts zero schema errors. Add negative tests proving missing owner, evidence, expiry, decision date, policy version, privacy class, and required supersession links fail with exact schema paths.
-
-Add behavior tests:
-
-```js
-test("expired approval fails closed", () => {
- const issues = validateApproval(expiredApproval, {
- now: "2026-07-18T00:00:00Z",
- action: "production_deployment",
- actor: "builder-agent",
- });
- assert.equal(issues.some((issue) => issue.code === "APPROVAL_EXPIRED"), true);
-});
-
-test("revoked approval fails closed", () => {
- const issues = validateApproval(revokedApproval, validContext);
- assert.equal(issues.some((issue) => issue.code === "APPROVAL_REVOKED"), true);
-});
-```
-
-- [ ] **Step 2: Run record tests and verify failure**
-
-Run: `node --test tests/records.test.mjs`
-
-Expected: FAIL because record schemas, templates, and approval validation are absent.
-
-- [ ] **Step 3: Implement shared record structure in each schema**
-
-Every schema requires stable ID, record type, schema version, policy version, timestamps, owner, affected business, status, evidence references, related records, privacy classification, and immutable-history references. Use `additionalProperties: false`, ISO date-time formats, declared enums, and nonempty arrays where evidence is mandatory.
-
-- [ ] **Step 4: Implement type-specific record requirements**
-
-- Approval: approver, requester, actor, action class, scope, evidence snapshot, cash/labor/duration/data/risk limits, decision, rationale, effective/expiry/review timestamps, revocation state and reference.
-- Decision: problem, hypothesis, confidence, evidence, counterevidence, alternatives, expected outcome, metric, decision, owner, review date, actual outcome, and confidence update.
-- Experiment: full preregistration, approval references, actual cost, results, reflection, outcome, and Experience reference.
-- Experience: all approved evidence, confidence, validity, relation, contradiction, supersession, lesson, and reuse fields.
-- Constitutional Amendment: changed policy paths, rationale, evidence, Human approval, compatibility result, old/new version, effective date, exception expiry when applicable, and rollback version.
-
-- [ ] **Step 5: Create concrete valid templates**
-
-Use IDs prefixed `example-`, policy version `2.0.0`, principal `genesis-owner` where Human approval is required, and dates in July 2026. Templates are explicit examples, not authorization for real actions. Set cash limits to `0` unless a value demonstrates a class threshold.
-
-- [ ] **Step 6: Implement approval validity**
-
-Export `validateApproval`. Parse dates strictly, reject invalid ranges, check revocation before scope, require exact or declared wildcard scope, and require actor match. Human approval records must use approver `genesis-owner`.
-
-- [ ] **Step 7: Add expired and revoked fixtures**
-
-Make both fixtures schema-valid so they fail behavior validation rather than structural validation. The expired fixture ends before the fixed test clock; the revoked fixture has `revoked: true` and a nonempty revocation reference.
-
-- [ ] **Step 8: Run record and full validation tests**
-
-Run: `node --test tests/records.test.mjs`
-
-Expected: all record tests pass.
-
-Run: `npm test`
-
-Expected: all configuration, invariant, and record tests pass.
-
-- [ ] **Step 9: Commit the records checkpoint**
-
-```bash
-git add schemas/records templates tests/records.test.mjs tests/fixtures/invalid/expired-approval.yaml tests/fixtures/invalid/revoked-approval.yaml scripts/validate-genesis.mjs
-git commit -m "feat: add canonical Genesis operating records"
-```
-
----
-
-### Task 7: Constitution, configuration guide, and active agent instructions
-
-**Files:**
-- Modify: `Genesis.md`
-- Modify: `Genesis Configuration.md`
-- Create: `AGENTS.md`
-- Modify: `codex.md.md`
-- Modify: `tests/configuration.test.mjs`
-- Modify: `scripts/validate-genesis.mjs`
-
-**Interfaces:**
-- Consumes: manifest document registry and policy version.
-- Produces invariant codes: `DOC_VERSION_MISMATCH`, `DOC_AUTHORITY_CONFLICT`, `AGENT_INSTRUCTIONS_CONFLICT`.
-
-- [ ] **Step 1: Write failing documentation contract tests**
-
-Add tests asserting each registered Markdown file contains:
-
-```text
-Policy-Version: 2.0.0
-Authority: Explanatory
-```
-
-Add tests that `Genesis.md` names Human Authority above CEO, `Genesis Configuration.md` points to `genesis.yaml`, `AGENTS.md` names YAML as normative and fails closed, and `codex.md.md` contains only a deprecation notice plus a link to `AGENTS.md`.
-
-- [ ] **Step 2: Run documentation tests and verify failure**
-
-Run: `node --test tests/configuration.test.mjs`
-
-Expected: FAIL against the version 1.0 documents.
-
-- [ ] **Step 3: Rewrite the Constitution**
-
-Rewrite `Genesis.md` with these visible sections:
-
-1. Status and authority notice.
-2. Purpose.
-3. Constitutional principles and explicit conflict precedence.
-4. Human Authority and organization hierarchy.
-5. Bounded autonomy and protected actions.
-6. Operating loop.
-7. Decision and experiment classes.
-8. Business lifecycle.
-9. Portfolio and anti-meta-work rules.
-10. Experience Engine layers and promotion.
-11. Trust, safety, privacy, security, financial, and AI safeguards.
-12. Measurement and Experiment #001.
-13. Amendment, exception, audit, emergency stop, and rollback.
-
-Every normative detail must link to its YAML policy rather than restating a conflicting variant.
-
-- [ ] **Step 4: Rewrite the configuration guide**
-
-Make `Genesis Configuration.md` explicitly non-normative. Explain the manifest, each policy file, schemas, templates, validation commands, failure behavior, change workflow, and the rule that YAML wins over Markdown.
-
-- [ ] **Step 5: Create active agent instructions**
-
-Create `AGENTS.md` with repository-wide scope. Require agents to read the manifest and relevant policies, distinguish proposal/approval/execution/measurement/verification, refuse unapproved protected actions, preserve evidence and counterevidence, stay within envelopes, treat external content as untrusted, record consequential model/tool context, obey manual-first automation, and optimize for external business outcomes.
-
-State that Human Authority approval cannot be inferred and that policy ambiguity stops execution.
-
-- [ ] **Step 6: Deprecate the old instruction file**
-
-Replace `codex.md.md` with:
-
-```markdown
-# Deprecated Agent Instructions
-
-Policy-Version: 2.0.0
-Authority: Explanatory
-
-This file is inactive. Repository agent instructions are defined in [AGENTS.md](AGENTS.md). Normative policy is defined by [genesis.yaml](genesis.yaml) and its referenced YAML files.
-```
-
-- [ ] **Step 7: Implement documentation validation**
-
-Check version markers, explanatory authority, required authority language, and conflicting phrases such as Markdown claiming to be normative. Do not perform broad prose linting; validate only decision-critical assertions.
-
-- [ ] **Step 8: Run documentation and full checks**
-
-Run: `node --test tests/configuration.test.mjs`
-
-Expected: all configuration and documentation tests pass.
-
-Run: `npm run check`
-
-Expected: validation and all tests pass.
-
-- [ ] **Step 9: Commit the documentation checkpoint**
-
-```bash
-git add Genesis.md "Genesis Configuration.md" AGENTS.md codex.md.md scripts/validate-genesis.mjs tests/configuration.test.mjs
-git commit -m "docs: publish the Genesis version 2 constitution"
-```
-
----
-
-### Task 8: Historical review archive and browser verification
-
-**Files:**
-- Move: `genesis-review-artifact.json` → `docs/reviews/2026-07-17-genesis-v1-review-artifact.json`
-- Move: `genesis-system-review.html` → `docs/reviews/2026-07-17-genesis-v1-system-review.html`
-- Create: `docs/reviews/README.md`
-- Modify: `docs/reviews/2026-07-17-genesis-v1-review-artifact.json`
-- Modify: `tests/configuration.test.mjs`
-
-**Interfaces:**
-- Consumes: the portable report builder already used for the review.
-- Produces: historical review marked non-normative and tied to Genesis version 1.0.
-
-- [ ] **Step 1: Add a failing archive test**
-
-Assert the two review files exist under `docs/reviews`, the README calls them historical and non-normative, and the artifact title or description identifies version 1.0.
-
-- [ ] **Step 2: Run the archive test and verify failure**
-
-Run: `node --test tests/configuration.test.mjs`
-
-Expected: FAIL because the review files remain at repository root.
-
-- [ ] **Step 3: Move and label the review artifacts**
-
-Resolve the exact two source paths before moving. Move them to the approved names, update the JSON artifact's title and description to identify the review as historical version 1.0 evidence, and update any safe relative provenance paths that changed due to the move.
-
-Create `docs/reviews/README.md` with policy version, explanatory authority, historical purpose, review date, reviewed version, and a warning that the report is not current policy.
-
-- [ ] **Step 4: Rebuild and verify with installed Chromium**
-
-Run:
-
-```bash
-CHROMIUM_EXECUTABLE_PATH=/home/zee/.cache/ms-playwright/chromium-1228/chrome-linux64/chrome node /home/zee/.codex/plugins/cache/openai-curated-remote/data-analytics/0.2.8-13ceeea1f599/skills/build-report/scripts/deliver_portable_artifact.mjs --input docs/reviews/2026-07-17-genesis-v1-review-artifact.json --output docs/reviews/2026-07-17-genesis-v1-system-review.html
-```
-
-Expected: `ok: true`, validation `passed`, package `passed`, and verification `passed`. If Chromium cannot start because shared libraries are unavailable, retain structural verification, record the exact missing library, and do not install system packages with a password in a shell command.
-
-- [ ] **Step 5: Run archive tests**
-
-Run: `node --test tests/configuration.test.mjs`
-
-Expected: all archive, configuration, and documentation tests pass.
-
-- [ ] **Step 6: Commit the historical-review checkpoint**
-
-```bash
-git add docs/reviews tests/configuration.test.mjs
-git add -u
-git commit -m "docs: archive the Genesis version 1 review"
-```
-
-Git should record the two root files as renames or deletions and the archive files as additions.
-
----
-
-### Task 9: GitHub Actions and full acceptance verification
-
-**Files:**
-- Create: `.github/workflows/validate-genesis.yml`
-- Modify: `package.json` only if the verified command names differ from Task 1.
-- Create: `docs/verification/2026-07-17-genesis-v2-validation.md`
-
-**Interfaces:**
-- Consumes: `npm ci`, `npm run validate`, `npm test`, and all local validation interfaces.
-- Produces: one CI job named `validate-genesis` and a local verification record.
-
-- [ ] **Step 1: Create the CI workflow**
-
-Create `.github/workflows/validate-genesis.yml`:
-
-```yaml
-name: Validate Genesis
-
-on:
- pull_request:
- push:
- branches: [main]
-
-permissions:
- contents: read
-
-jobs:
- validate-genesis:
- runs-on: ubuntu-latest
- timeout-minutes: 10
- steps:
- - uses: actions/checkout@v4
- - uses: actions/setup-node@v4
- with:
- node-version: 22
- cache: npm
- - run: npm ci
- - run: npm run validate
- - run: npm test
-```
-
-- [ ] **Step 2: Run the clean-install acceptance gate**
-
-Run: `npm ci`
-
-Expected: exit 0 using `package-lock.json` without dependency changes.
-
-Run: `npm run validate`
-
-Expected: `Genesis policy 2.0.0 is valid.` and exit 0.
-
-Run: `npm test`
-
-Expected: every configuration, invariant, record, negative-fixture, documentation, and archive test passes with zero failures.
-
-- [ ] **Step 3: Re-run every required negative case explicitly**
-
-Run the individual tests for invalid Human Authority, protected action without Human approval, expired approval, revoked approval, forbidden transition, Micro over budget, and allocation mismatch.
-
-Expected: each fixture is rejected with its exact invariant or approval code, while the test process exits 0 because rejection is the asserted behavior.
-
-- [ ] **Step 4: Scan for secrets and unresolved markers**
-
-Run:
-
-```bash
-rg -n --hidden -g '!node_modules/**' -g '!docs/reviews/*.html' '(BEGIN (RSA|OPENSSH|EC) PRIVATE KEY|api[_-]?key\s*[:=]|password\s*[:=]|secret\s*[:=])' .
-```
-
-Expected: no matches containing actual credentials. Policy field names such as `secret_handling` are allowed only when they contain no secret value.
-
-Run:
-
-```bash
-rg -n --hidden -g '!node_modules/**' -g '!docs/reviews/**' '\b(T[B]D|FIXM[E]|X[X]X)\b' .
-```
-
-Expected: no matches.
-
-- [ ] **Step 5: Write the verification record**
-
-Create `docs/verification/2026-07-17-genesis-v2-validation.md` containing:
-
-- Policy version and validation timestamp.
-- Node and npm versions.
-- Commands executed.
-- Test counts and zero-failure result.
-- Negative cases proven.
-- Historical report browser-verification status.
-- Git repository limitation if `.git` is still invalid.
-- Statement that runtime automation and production deployment remain outside scope.
-
-- [ ] **Step 6: Run the final verification from a fresh process**
-
-Run: `npm run check`
-
-Expected: validation succeeds and all tests pass.
-
-Run: `node scripts/validate-genesis.mjs .`
-
-Expected: `Genesis policy 2.0.0 is valid.`
-
-- [ ] **Step 7: Commit the CI and verification checkpoint**
-
-```bash
-git add .github/workflows/validate-genesis.yml docs/verification/2026-07-17-genesis-v2-validation.md package.json package-lock.json
-git commit -m "ci: enforce Genesis governance validation"
-```
-
-- [ ] **Step 8: Inspect the final repository state**
-
-Run: `git status --short`
-
-Expected after Git restoration and commits: no unintended changes. If the repository remains unavailable, list all created and modified files using `rg --files` and hand them off without claiming commit or push status.
-
-## Execution Notes
-
-- Do not initialize, repair, or change permissions on `.git` without explicit authorization.
-- Do not push or create a GitHub repository until the destination and publication authority are provided.
-- If dependency installation is blocked by network policy, request approval for `npm install`; do not replace schema validation with an unreviewed custom implementation.
-- If a later task exposes a contradiction in the approved specification, stop that task and amend the specification before weakening a control.
diff --git a/docs/superpowers/specs/2026-07-17-genesis-operating-system-design.md b/docs/superpowers/specs/2026-07-17-genesis-operating-system-design.md
deleted file mode 100644
index 06b99e5..0000000
--- a/docs/superpowers/specs/2026-07-17-genesis-operating-system-design.md
+++ /dev/null
@@ -1,547 +0,0 @@
-# Genesis Operating System Design
-
-**Status:** Approved design
-**Date:** July 17, 2026
-**Scope:** Governance and operating-spec package for Genesis version 2.0
-
-## 1. Purpose
-
-Genesis is a human-governed business manufacturing system. It repeatedly discovers, validates, builds, operates, and improves trustworthy businesses while preserving evidence, correcting beliefs, and constraining autonomous action.
-
-This design converts the existing manifesto into an executable policy package. It does not build an autonomous workflow engine. Genesis must first operate manually under these policies and earn automation through repeated, measured need.
-
-## 2. Design Decisions
-
-The approved decisions are:
-
-1. One Human Authority is the final authority above the CEO.
-2. Genesis uses bounded autonomy: agents may act within pre-approved low-risk envelopes, while protected actions require Human Authority approval.
-3. YAML is the normative source of truth. Markdown is explanatory and cannot override YAML.
-4. The repository includes executable validation infrastructure: JSON Schemas, a validation script, tests, and GitHub Actions.
-5. Policy is modular rather than monolithic.
-6. The first implementation is a manual operating protocol, not an automated orchestration engine.
-
-## 3. Goals and Non-Goals
-
-### Goals
-
-- Establish unambiguous authority, permissions, approvals, and escalation.
-- Define enforceable business and experiment lifecycles.
-- Prevent Genesis from optimizing for its own internal machinery.
-- Preserve evidence without turning memory into an uncurated archive.
-- Make decision quality measurable.
-- Fail closed when policy is missing, invalid, contradictory, or expired.
-- Validate all normative configuration locally and in GitHub Actions.
-- Provide reusable, schema-valid records for decisions, experiments, approvals, experiences, and constitutional amendments.
-
-### Non-Goals
-
-- Autonomous orchestration of agents.
-- A workflow execution service or policy engine.
-- A production database.
-- Vector search or automated principle extraction.
-- Customer billing, identity, or application infrastructure.
-- Market-specific legal or regulatory certification.
-
-These capabilities require separate designs after the manual protocol generates sufficient evidence.
-
-## 4. Source of Truth and Precedence
-
-`genesis.yaml` is the normative root manifest. It identifies every normative policy file, schema, policy version, and required validation rule.
-
-Precedence is:
-
-1. A valid, unrevoked Human Authority emergency suspension record.
-2. The currently effective normative YAML policy set referenced by `genesis.yaml`.
-3. Valid, scoped, unexpired Human Authority approval records.
-4. Valid CEO approvals within Human-approved envelopes.
-5. Markdown documentation and agent guidance.
-
-Lower-precedence material cannot override higher-precedence policy. An invalid YAML policy set has no authority. If normative files disagree, validation fails and affected execution is suspended.
-
-Markdown must state its non-normative status and policy version. Documentation drift is a validation failure when a document claims a different authority hierarchy or policy version.
-
-## 5. Repository Architecture
-
-```text
-Genesis/
-├── genesis.yaml
-├── Genesis.md
-├── Genesis Configuration.md
-├── AGENTS.md
-├── codex.md.md
-├── package.json
-├── config/
-│ ├── governance.yaml
-│ ├── organization.yaml
-│ ├── permissions.yaml
-│ ├── decision-policy.yaml
-│ ├── portfolio-policy.yaml
-│ ├── experience-policy.yaml
-│ ├── risk-policy.yaml
-│ ├── metrics-policy.yaml
-│ └── workflows/
-│ ├── business-lifecycle.yaml
-│ └── experiment-lifecycle.yaml
-├── templates/
-│ ├── approval-record.yaml
-│ ├── decision-record.yaml
-│ ├── experiment-record.yaml
-│ ├── experience-record.yaml
-│ └── constitutional-amendment.yaml
-├── schemas/
-│ ├── genesis.schema.json
-│ ├── governance.schema.json
-│ ├── organization.schema.json
-│ ├── permissions.schema.json
-│ ├── decision-policy.schema.json
-│ ├── portfolio-policy.schema.json
-│ ├── experience-policy.schema.json
-│ ├── risk-policy.schema.json
-│ ├── metrics-policy.schema.json
-│ ├── workflow.schema.json
-│ └── records/
-│ ├── approval-record.schema.json
-│ ├── decision-record.schema.json
-│ ├── experiment-record.schema.json
-│ ├── experience-record.schema.json
-│ └── constitutional-amendment.schema.json
-├── scripts/
-│ └── validate-genesis.mjs
-├── tests/
-│ ├── configuration.test.mjs
-│ ├── invariants.test.mjs
-│ └── records.test.mjs
-├── docs/
-│ ├── reviews/
-│ └── superpowers/
-└── .github/workflows/
- └── validate-genesis.yml
-```
-
-Each file has one responsibility. Policy files contain normative rules, schemas validate their local structure, and cross-file tests enforce system-wide invariants.
-
-## 6. Authority Model
-
-The mandatory hierarchy is:
-
-```text
-Human Authority
-└── CEO
- ├── Research
- ├── Builder
- ├── Operator
- └── Analyst
-```
-
-### Human Authority
-
-The Human Authority is a single identified human principal with the stable principal ID `genesis-owner`. This identity cannot be assigned to an AI agent, service account, delegated agent, or autonomous process.
-
-The Human Authority may:
-
-- Approve, reject, veto, revoke, or suspend any Genesis action.
-- Define and revoke budget and permission envelopes.
-- Approve Major Bets and protected actions.
-- Amend the Constitution and normative policy.
-- Approve temporary policy exceptions.
-- Activate or release the emergency stop.
-- Appoint, replace, constrain, or remove the CEO.
-
-Human Authority approval cannot be inferred from silence, prior behavior, conversation context, or a role label. It exists only as a valid approval record attributable to `genesis-owner`.
-
-### CEO
-
-The CEO is accountable to the Human Authority. The CEO allocates resources within approved envelopes, approves normal Experiments, resolves operational conflicts, enforces policy, and reports material risk. The CEO cannot amend policy, approve its own permission escalation, waive protected-action controls, or overrule the Human Authority.
-
-### Functions
-
-Research, Builder, Operator, and Analyst operate within explicit responsibility and permission scopes. A function may propose actions outside its scope but may not execute them until the required approval exists.
-
-For Major Bets, protected actions, permission escalation, and constitutional actions, proposer and approver must be different principals.
-
-## 7. Bounded Autonomy and Permissions
-
-Genesis is default-deny. An action is permitted only when all of the following are true:
-
-1. The policy set is valid and effective.
-2. The actor has the required role and permission.
-3. The action is within an unexpired budget, duration, data, and risk envelope.
-4. Every required approval exists and is valid.
-5. No emergency suspension or revocation applies.
-6. Required evidence and records are present.
-
-Agents may perform reversible, low-risk internal work such as public-source research, internal drafting, sandboxed code changes, tests, analysis, and record preparation when those actions stay within declared scopes.
-
-### Protected Actions
-
-The following require Human Authority approval regardless of cost:
-
-- Contracts, terms, warranties, or legal commitments.
-- Spending outside an approved portfolio envelope.
-- Banking authority, payments, refunds, transfers, or changes to financial authority.
-- Production deployment or an irreversible production change.
-- Collection, purchase, sharing, or processing of personal or sensitive data.
-- External customer communication outside an approved template and audience.
-- Regulated-market activity or regulated claims.
-- Security exceptions, credential access, permission escalation, or access-control changes.
-- Public claims or representations made on behalf of Genesis.
-- Creation or dissolution of a legal entity.
-- Constitutional amendments or policy exceptions.
-- Any action classified high or critical risk.
-
-### Approval Records
-
-Every approval contains:
-
-- Record ID and policy version.
-- Human or CEO approver principal ID.
-- Requester and affected actor.
-- Action class and exact scope.
-- Evidence snapshot references.
-- Maximum cash, labor, duration, data, and risk limits.
-- Decision and rationale.
-- Issued, effective, expiry, and review timestamps.
-- Revocation status and revocation reference.
-
-Missing, expired, revoked, mismatched, or ambiguous approval means deny.
-
-## 8. Decision Classes
-
-### Routine
-
-- Reversible internal work.
-- No external commitment.
-- No protected action.
-- No incremental spend outside an existing envelope.
-- Governed by role permissions.
-
-### Micro-Experiment
-
-- Maximum direct cash cost: USD 500.
-- Maximum duration: seven calendar days.
-- Must fit within a Human-approved aggregate portfolio envelope.
-- Must have a pre-registered hypothesis, metric, stop condition, owner, and decision date.
-- May be approved by the CEO or run under an explicit standing CEO approval.
-
-### Experiment
-
-- Maximum direct cash cost: USD 5,000.
-- Maximum duration: 30 calendar days.
-- Requires CEO approval.
-- Must fit within a Human-approved aggregate portfolio envelope.
-- Cannot contain a protected action without separate Human Authority approval.
-
-### Major Bet
-
-An action is a Major Bet if any of these are true:
-
-- Direct cash cost exceeds USD 5,000.
-- Planned duration exceeds 30 calendar days.
-- It creates a strategically difficult-to-reverse commitment.
-- It spans multiple businesses or materially changes the portfolio.
-- It consumes more than 20% of monthly deployable cash or available operating capacity.
-- It is classified high risk.
-
-Major Bets require Constitution review, evidence review, CEO recommendation, and Human Authority approval.
-
-### Constitutional Action
-
-Any change to authority, precedence, protected actions, decision thresholds, amendment rules, or the normative policy set is a Constitutional Action. It requires Human Authority approval and change control.
-
-## 9. Business Lifecycle
-
-The states are:
-
-```text
-Discover → Validate → Build → Launch → Operate → Review
- ├→ Scale
- ├→ Pivot
- ├→ Learning Lab
- ├→ Archive
- └→ Kill
-```
-
-Every state defines:
-
-- Accountable role.
-- Responsible function.
-- Required input records.
-- Allowed actions.
-- Exit criteria.
-- Required evidence.
-- Approval class.
-- Output record.
-- Review deadline.
-- Allowed next states.
-
-Build cannot begin unless a validation record meets its precommitted pass criteria. The sole exception is a Human-approved learning prototype with a capped budget, explicit non-production scope, and expiry.
-
-Launch requires Human Authority approval because it introduces external users, production operation, public representation, or customer data. Routine production operations may later run under a scoped, expiring standing approval.
-
-## 10. Experiment Lifecycle
-
-The states are:
-
-```text
-Draft → Evidence Review → Approval → Running → Measurement
- → Reflection → Decision → Closed
-```
-
-Before approval, an experiment must specify:
-
-- Problem and decision it supports.
-- Hypothesis and confidence.
-- Evidence and counterevidence.
-- Baseline and comparison method.
-- Metric formula, population, denominator, and data source.
-- Expected outcome and minimum meaningful effect.
-- Failure and stop conditions.
-- Maximum cash, labor, duration, data, and risk exposure.
-- Owner and decision date.
-- Allowed outcomes.
-
-Allowed outcomes are Scale, Pivot, Learning Lab, Archive, and Kill. Closure requires actual cost, outcome, reflection, confidence update, and linked Experience Record.
-
-## 11. Portfolio Policy and Meta-Work Controls
-
-Genesis has two portfolio modes.
-
-### Bootstrap Mode
-
-- 80% primary opportunity discovery, validation, building, or operation.
-- 15% bounded experiments.
-- 5% reserve; Learning Labs may use it only after a real failed initiative qualifies.
-- Maximum one active business opportunity.
-- Maximum 10% of total weekly capacity on the Genesis operating system itself.
-
-### Operating Mode
-
-- 80% proven businesses or opportunities.
-- 15% experiments.
-- 5% Learning Labs.
-
-`proven` means evidence of repeatable demand, a defined customer, a working value-delivery process, and economics or strategic value meeting precommitted thresholds.
-
-Additional controls are:
-
-- No automation until the same material manual failure occurs at least three times.
-- Every internal artifact must name the external decision it changes, its owner, review date, and sunset date.
-- Producing records, dashboards, frameworks, prompts, or automation is not a business outcome.
-- Work without a current external decision or validated operational need is stopped.
-- Learning Labs require a budget, owner, learning metric, monthly review, and mandatory expiry.
-- Aggregate experiment spending cannot exceed the active Human-approved envelope even if each experiment is individually below its class limit.
-
-## 12. Canonical Records
-
-Decision, Experiment, Approval, Experience, and Constitutional Amendment records share:
-
-- Stable ID.
-- Record type and schema version.
-- Policy version.
-- Created and updated timestamps.
-- Owner and affected business.
-- Current status.
-- Evidence references.
-- Related-record references.
-- Privacy classification.
-- Immutable history references.
-
-The Decision–Experiment–Outcome model is the shared spine. Specialized record types extend it without redefining common concepts.
-
-## 13. Experience Engine
-
-The Experience Engine separates history from current knowledge.
-
-### Immutable Evidence Layer
-
-This append-only layer stores decisions, experiments, approvals, observations, actions, and outcomes. Corrections are new records that reference prior records; historical evidence is never silently rewritten.
-
-### Curated Knowledge Layer
-
-This layer contains the current best lessons and principles. Each item links to supporting and contradicting evidence, carries confidence and validity dates, and identifies records it supersedes.
-
-The promotion path is:
-
-```text
-Raw Event → Reviewed Experience → Validated Lesson → Principle
-```
-
-A single event cannot automatically become a universal principle. A broad principle requires replicated evidence or explicit Human Authority approval. Human approval does not convert weak evidence into strong evidence; it authorizes provisional use and must preserve the limitation.
-
-Required Experience fields include:
-
-- ID, timestamp, owner, business, domain, and tags.
-- Context, hypothesis, decision, action, and outcome.
-- Baseline, expected result, metric definition, and actual result.
-- Supporting and contradicting evidence.
-- Confidence and validity window.
-- Privacy classification and review status.
-- Related, duplicate, contradicts, and supersedes references.
-- Reflection, reusable lesson, and reuse evidence.
-
-The initial store is a reviewed YAML record collection. Retrieval starts with keyword and field search plus weekly Human-curated synthesis. Vector search, automated extraction, and a database require a separate evidence-backed design.
-
-Experience quality is measured by retrieval success, contradiction resolution time, lesson reuse, changed decisions, stale-record rate, and avoided cost or rework.
-
-## 14. Metrics and Genesis Experiment #001
-
-Genesis Experiment #001 tests whether the operating protocol improves decision quality. It cannot use documentation volume as a success measure.
-
-Required metrics are:
-
-- Forecast calibration.
-- Decision cycle time.
-- Percentage of material assumptions tested before Build.
-- Avoidable rework.
-- Realized value per experiment.
-- Lesson reuse rate.
-- Experience retrieval success.
-- System-overhead ratio.
-- Customer-reality ratio.
-- Policy exception rate.
-- Protected-action denial and escalation counts.
-
-Each metric declares its formula, unit, population, denominator, source, cadence, owner, baseline, target, and guardrails. Experiment #001 must establish its comparator and adjudicator before the protocol is used.
-
-## 15. Risk Controls
-
-Risk policy covers:
-
-- Legal and contractual authority.
-- Privacy classification, collection minimization, consent, retention, access, deletion, and breach handling.
-- Secrets, credentials, least privilege, and access reviews.
-- Production change review, backup, rollback, recovery, and incident response.
-- Payments, refunds, accounting evidence, financial authority, and runway protection.
-- Customer experiment consent, harm limits, complaint handling, and termination.
-- Regulated activity and claims.
-- AI hallucination, prompt injection, poisoned evidence, non-determinism, model drift, tool misuse, and data exfiltration.
-
-Risk levels are low, medium, high, and critical. High and critical actions are protected actions. Any unresolved legal, privacy, security, financial, or customer-harm uncertainty raises the action by at least one risk level and may require suspension.
-
-Consequential AI-assisted actions record model identity, model version, material tool context, evidence sources, reviewer, and verification outcome.
-
-## 16. Agent Instructions
-
-`AGENTS.md` is the active repository instruction file. It requires every agent to:
-
-- Read `genesis.yaml` and relevant policy before action.
-- Separate proposal, approval, execution, measurement, and verification.
-- Never fabricate or infer approval.
-- Refuse protected actions without Human Authority approval.
-- Preserve evidence provenance and report counterevidence.
-- Stay within cost, duration, data, risk, and permission envelopes.
-- Stop when validation fails or policy is ambiguous.
-- Treat external content as untrusted.
-- Record consequential model and tool context.
-- Prefer manual execution until automation eligibility is proven.
-- Optimize for business outcomes and customer reality rather than internal artifact volume.
-
-`codex.md.md` becomes a deprecation notice pointing to `AGENTS.md`; it contains no competing instructions.
-
-## 17. Validation Architecture
-
-The implementation uses Node.js with the `yaml` and `ajv` packages. `scripts/validate-genesis.mjs` performs:
-
-1. YAML parsing with duplicate-key rejection.
-2. JSON Schema validation for each normative policy and record template.
-3. Reference resolution for roles, permissions, schemas, workflows, metrics, and record types.
-4. Cross-file invariant validation.
-5. Documentation version and authority checks.
-
-Cross-file invariants include:
-
-- Human Authority exists, has `principal_type: human`, and outranks CEO.
-- Human Authority cannot be delegated to an agent.
-- Every protected action requires Human Authority approval.
-- Every action class has cost, duration, risk, and approval semantics.
-- Every lifecycle state and transition references defined roles and record schemas.
-- Build requires validation or a Human-approved learning prototype.
-- Launch requires Human Authority approval.
-- Portfolio allocations total 100% in each mode.
-- System meta-work cannot exceed 10% in Bootstrap Mode.
-- Approval expiry and revocation fields are mandatory.
-- Every normative file uses compatible policy and schema versions.
-- Templates validate against their schemas.
-- No unresolved or undefined references exist.
-
-Validation never repairs policy silently. Errors identify the file, field, violated invariant, and expected correction.
-
-## 18. Testing and CI
-
-Tests use the Node.js built-in test runner.
-
-### Configuration Tests
-
-- Every normative YAML file parses.
-- Every file matches its schema.
-- The manifest references every required file and no missing file.
-
-### Invariant Tests
-
-- Human Authority outranks CEO.
-- An AI principal cannot occupy Human Authority.
-- Protected actions cannot omit Human approval.
-- Expired or revoked approval fails.
-- Excess budget changes an Experiment into a Major Bet.
-- Invalid lifecycle transitions fail.
-- Allocation totals other than 100% fail.
-- Documentation cannot claim normative authority.
-
-### Record Tests
-
-- Every template validates.
-- Missing provenance, owner, expiry, decision date, or supersession fields fail where required.
-- Record references use declared types and identifiers.
-
-GitHub Actions installs locked dependencies with `npm ci`, runs validation, and runs all tests. Pull requests cannot be treated as policy-ready unless the workflow passes.
-
-## 19. Error Handling and Emergency Control
-
-Genesis fails closed. A missing, invalid, contradictory, ambiguous, expired, or revoked policy or approval denies the affected action.
-
-Errors must be actionable and include:
-
-- Error code.
-- File and field path.
-- Actor and attempted action when applicable.
-- Required policy or approval.
-- Escalation target.
-
-The emergency stop disables agent execution, spending, external communication, production changes, and new approvals below Human Authority. Read-only inspection, evidence preservation, validation, and recovery planning remain available.
-
-Release from emergency stop requires a Human Authority record identifying cause, remediation evidence, restored scopes, and monitoring period.
-
-## 20. Migration
-
-- Rewrite `Genesis.md` as the readable Constitution for policy version 2.0.
-- Rewrite `Genesis Configuration.md` as a non-normative configuration guide.
-- Create `AGENTS.md` as the active instruction file.
-- Replace `codex.md.md` with a deprecation notice.
-- Move the version 1.0 review artifact and HTML report to `docs/reviews/` and mark them historical.
-- Preserve the approved design and implementation plan under `docs/superpowers/`.
-
-The historical review remains evidence of why version 2.0 changed. It cannot be used as current policy.
-
-## 21. Acceptance Criteria
-
-The governance package is ready when:
-
-1. Every YAML file parses with duplicate keys rejected.
-2. Every normative file passes its JSON Schema.
-3. Every record template passes its schema.
-4. All cross-file invariants pass.
-5. Negative fixtures fail for invalid authority, expired approval, revoked approval, forbidden transition, excess budget, allocation mismatch, and protected action without Human approval.
-6. Human Authority is provably above CEO and typed as human.
-7. No protected action can pass validation without Human Authority approval semantics.
-8. Constitution, configuration guide, agent instructions, and normative policy contain no authority or workflow contradiction.
-9. GitHub Actions runs the same validation and tests as local development.
-10. The portable historical review passes browser verification when Chromium is available.
-11. The repository contains no placeholders, embedded secrets, or conflicting active instruction files.
-12. No runtime automation is introduced beyond validation and tests.
-
-## 22. Git and GitHub Handoff
-
-The current workspace contains an empty, read-only `.git` directory and is not recognized as a Git repository. The implementation can prepare all files and verification evidence, but commits and GitHub publication require restoration of real repository metadata or creation of a writable repository by the user or authorized environment.
-
-GitHub publication is a separate authorized action after local validation passes and the destination repository is known.
diff --git a/package-lock.json b/package-lock.json
index 7301ecc..9d5c613 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -7,11 +7,15 @@
"": {
"name": "genesis-governance",
"version": "2.0.0",
- "devDependencies": {
+ "dependencies": {
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
+ "better-sqlite3": "12.11.1",
"yaml": "2.8.3"
},
+ "bin": {
+ "genesis": "bin/genesis.mjs"
+ },
"engines": {
"node": ">=22"
}
@@ -20,7 +24,6 @@
"version": "8.18.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.18.0.tgz",
"integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"fast-deep-equal": "^3.1.3",
@@ -37,7 +40,6 @@
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/ajv-formats/-/ajv-formats-3.0.1.tgz",
"integrity": "sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"ajv": "^8.0.0"
@@ -51,18 +53,151 @@
}
}
},
+ "node_modules/base64-js": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/better-sqlite3": {
+ "version": "12.11.1",
+ "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.11.1.tgz",
+ "integrity": "sha512-dq9AtApgg5PGFtBzPFSBl3HZQjHok5gaQCM6zh2Yk0aSmDCs1CbnVI8/HgASQkNKsWFpseIO9beg5xxpYhbIfA==",
+ "hasInstallScript": true,
+ "license": "MIT",
+ "dependencies": {
+ "bindings": "^1.5.0",
+ "prebuild-install": "^7.1.1"
+ },
+ "engines": {
+ "node": "20.x || 22.x || 23.x || 24.x || 25.x || 26.x"
+ }
+ },
+ "node_modules/bindings": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/bindings/-/bindings-1.5.0.tgz",
+ "integrity": "sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==",
+ "license": "MIT",
+ "dependencies": {
+ "file-uri-to-path": "1.0.0"
+ }
+ },
+ "node_modules/bl": {
+ "version": "4.1.0",
+ "resolved": "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz",
+ "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==",
+ "license": "MIT",
+ "dependencies": {
+ "buffer": "^5.5.0",
+ "inherits": "^2.0.4",
+ "readable-stream": "^3.4.0"
+ }
+ },
+ "node_modules/buffer": {
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
+ }
+ },
+ "node_modules/chownr": {
+ "version": "1.1.4",
+ "resolved": "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz",
+ "integrity": "sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==",
+ "license": "ISC"
+ },
+ "node_modules/decompress-response": {
+ "version": "6.0.0",
+ "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz",
+ "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==",
+ "license": "MIT",
+ "dependencies": {
+ "mimic-response": "^3.1.0"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/deep-extend": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
+ "integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=4.0.0"
+ }
+ },
+ "node_modules/detect-libc": {
+ "version": "2.1.2",
+ "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
+ "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8"
+ }
+ },
+ "node_modules/end-of-stream": {
+ "version": "1.4.5",
+ "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz",
+ "integrity": "sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg==",
+ "license": "MIT",
+ "dependencies": {
+ "once": "^1.4.0"
+ }
+ },
+ "node_modules/expand-template": {
+ "version": "2.0.3",
+ "resolved": "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz",
+ "integrity": "sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==",
+ "license": "(MIT OR WTFPL)",
+ "engines": {
+ "node": ">=6"
+ }
+ },
"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==",
- "dev": true,
"license": "MIT"
},
"node_modules/fast-uri": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.1.3.tgz",
"integrity": "sha512-i70LwGWUduXqzicKXWshooq+sWL1K3WUU5rKZNG/0i3a1OSoX3HqhH5WbWwTmqWfor4urUakGPiRQcleRZTwOg==",
- "dev": true,
"funding": [
{
"type": "github",
@@ -75,28 +210,342 @@
],
"license": "BSD-3-Clause"
},
+ "node_modules/file-uri-to-path": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/file-uri-to-path/-/file-uri-to-path-1.0.0.tgz",
+ "integrity": "sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==",
+ "license": "MIT"
+ },
+ "node_modules/fs-constants": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz",
+ "integrity": "sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==",
+ "license": "MIT"
+ },
+ "node_modules/github-from-package": {
+ "version": "0.0.0",
+ "resolved": "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz",
+ "integrity": "sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==",
+ "license": "MIT"
+ },
+ "node_modules/ieee754": {
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "BSD-3-Clause"
+ },
+ "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/ini": {
+ "version": "1.3.8",
+ "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
+ "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
+ "license": "ISC"
+ },
"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==",
- "dev": true,
"license": "MIT"
},
+ "node_modules/mimic-response": {
+ "version": "3.1.0",
+ "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz",
+ "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=10"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/sindresorhus"
+ }
+ },
+ "node_modules/minimist": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
+ "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ljharb"
+ }
+ },
+ "node_modules/mkdirp-classic": {
+ "version": "0.5.3",
+ "resolved": "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz",
+ "integrity": "sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==",
+ "license": "MIT"
+ },
+ "node_modules/napi-build-utils": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz",
+ "integrity": "sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA==",
+ "license": "MIT"
+ },
+ "node_modules/node-abi": {
+ "version": "3.94.0",
+ "resolved": "https://registry.npmjs.org/node-abi/-/node-abi-3.94.0.tgz",
+ "integrity": "sha512-W5ZNO5KRPB5TkYmGVD9F6YqhsglXJzE6etpbmT+f6EQElhiX/UTG551cnsRGvLG3fyZEg9HwaDmNmj5nwJ4z9g==",
+ "license": "MIT",
+ "dependencies": {
+ "semver": "^7.3.5"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "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/prebuild-install": {
+ "version": "7.1.3",
+ "resolved": "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz",
+ "integrity": "sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==",
+ "deprecated": "No longer maintained. Please contact the author of the relevant native addon; alternatives are available.",
+ "license": "MIT",
+ "dependencies": {
+ "detect-libc": "^2.0.0",
+ "expand-template": "^2.0.3",
+ "github-from-package": "0.0.0",
+ "minimist": "^1.2.3",
+ "mkdirp-classic": "^0.5.3",
+ "napi-build-utils": "^2.0.0",
+ "node-abi": "^3.3.0",
+ "pump": "^3.0.0",
+ "rc": "^1.2.7",
+ "simple-get": "^4.0.0",
+ "tar-fs": "^2.0.0",
+ "tunnel-agent": "^0.6.0"
+ },
+ "bin": {
+ "prebuild-install": "bin.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/pump": {
+ "version": "3.0.4",
+ "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz",
+ "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==",
+ "license": "MIT",
+ "dependencies": {
+ "end-of-stream": "^1.1.0",
+ "once": "^1.3.1"
+ }
+ },
+ "node_modules/rc": {
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
+ "integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
+ "license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
+ "dependencies": {
+ "deep-extend": "^0.6.0",
+ "ini": "~1.3.0",
+ "minimist": "^1.2.0",
+ "strip-json-comments": "~2.0.1"
+ },
+ "bin": {
+ "rc": "cli.js"
+ }
+ },
+ "node_modules/readable-stream": {
+ "version": "3.6.2",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz",
+ "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==",
+ "license": "MIT",
+ "dependencies": {
+ "inherits": "^2.0.3",
+ "string_decoder": "^1.1.1",
+ "util-deprecate": "^1.0.1"
+ },
+ "engines": {
+ "node": ">= 6"
+ }
+ },
"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==",
- "dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
+ "node_modules/safe-buffer": {
+ "version": "5.2.1",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz",
+ "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/semver": {
+ "version": "7.8.5",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
+ "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
+ "node_modules/simple-concat": {
+ "version": "1.0.1",
+ "resolved": "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz",
+ "integrity": "sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT"
+ },
+ "node_modules/simple-get": {
+ "version": "4.0.1",
+ "resolved": "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz",
+ "integrity": "sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==",
+ "funding": [
+ {
+ "type": "github",
+ "url": "https://github.com/sponsors/feross"
+ },
+ {
+ "type": "patreon",
+ "url": "https://www.patreon.com/feross"
+ },
+ {
+ "type": "consulting",
+ "url": "https://feross.org/support"
+ }
+ ],
+ "license": "MIT",
+ "dependencies": {
+ "decompress-response": "^6.0.0",
+ "once": "^1.3.1",
+ "simple-concat": "^1.0.0"
+ }
+ },
+ "node_modules/string_decoder": {
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz",
+ "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.2.0"
+ }
+ },
+ "node_modules/strip-json-comments": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
+ "integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
+ "license": "MIT",
+ "engines": {
+ "node": ">=0.10.0"
+ }
+ },
+ "node_modules/tar-fs": {
+ "version": "2.1.5",
+ "resolved": "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.5.tgz",
+ "integrity": "sha512-OboTd8mmMhZDNPV+UjQcK9yKAatXu2aJ+r1w4im1Otd4M4fl2hwvdoXUxIYHFTHWK/3y3FarBP70v3vwmGlOxw==",
+ "license": "MIT",
+ "dependencies": {
+ "chownr": "^1.1.1",
+ "mkdirp-classic": "^0.5.2",
+ "pump": "^3.0.0",
+ "tar-stream": "^2.1.4"
+ }
+ },
+ "node_modules/tar-stream": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz",
+ "integrity": "sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==",
+ "license": "MIT",
+ "dependencies": {
+ "bl": "^4.0.3",
+ "end-of-stream": "^1.4.1",
+ "fs-constants": "^1.0.0",
+ "inherits": "^2.0.3",
+ "readable-stream": "^3.1.1"
+ },
+ "engines": {
+ "node": ">=6"
+ }
+ },
+ "node_modules/tunnel-agent": {
+ "version": "0.6.0",
+ "resolved": "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz",
+ "integrity": "sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "safe-buffer": "^5.0.1"
+ },
+ "engines": {
+ "node": "*"
+ }
+ },
+ "node_modules/util-deprecate": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
+ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
+ "license": "MIT"
+ },
+ "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/yaml": {
"version": "2.8.3",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.8.3.tgz",
"integrity": "sha512-AvbaCLOO2Otw/lW5bmh9d/WEdcDFdQp2Z2ZUH3pX9U2ihyUY0nvLv7J6TrWowklRGPYbB/IuIMfYgxaCPg5Bpg==",
- "dev": true,
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
diff --git a/package.json b/package.json
index c598168..7580e93 100644
--- a/package.json
+++ b/package.json
@@ -6,14 +6,19 @@
"engines": {
"node": ">=22"
},
+ "bin": {
+ "genesis": "./bin/genesis.mjs"
+ },
"scripts": {
+ "start": "node bin/genesis.mjs",
"validate": "node scripts/validate-genesis.mjs",
"test": "node --test tests/*.test.mjs",
"check": "npm run validate && npm test"
},
- "devDependencies": {
+ "dependencies": {
"ajv": "8.18.0",
"ajv-formats": "3.0.1",
+ "better-sqlite3": "12.11.1",
"yaml": "2.8.3"
}
}
diff --git a/records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml b/records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml
new file mode 100644
index 0000000..f9daa51
--- /dev/null
+++ b/records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml
@@ -0,0 +1,53 @@
+id: approval-cli-discovery-pipeline-2026-07-17
+record_type: approval_record
+schema_version: 1.0.0
+policy_version: 2.0.0
+created_at: 2026-07-17T19:32:16Z
+updated_at: 2026-07-17T19:32:16Z
+owner: human_authority
+affected_business: genesis-system
+status: active
+evidence_references:
+ - conversation://current/genesis-owner/cli-discovery-pipeline-approval
+ - git://commit/5daa7fc
+ - command://npm-run-check/2026-07-17/pass
+related_records: []
+privacy_classification: internal
+immutable_history_refs:
+ - record://approval-cli-discovery-pipeline-2026-07-17/v1
+approver_role: human_authority
+approver_principal_id: genesis-owner
+requester: codex-agent
+actor: codex-agent
+action_class: protected_action
+scope:
+ actions:
+ - sandboxed_cli_pipeline_implementation
+ wildcard: false
+evidence_snapshot:
+ - model-identity://openai/codex
+ - model-version://gpt-5
+ - tool-context://git-npm-node-subagent-workflow
+ - plan://commit/5daa7fc
+ - reviewer://genesis-owner/analyst
+ - verification://npm-run-check/pass
+limits:
+ cash_usd: 0
+ labor_hours: 8
+ duration_days: 7
+ data_classes: [public, internal]
+ risk_level: high
+decision: approved
+rationale: >-
+ genesis-owner explicitly approved codex-agent to implement the protected,
+ sandboxed Genesis CLI discovery pipeline described by plan commit 5daa7fc.
+ Scope includes Decision and Experiment schema alignment, transition-validator
+ alignment, an offline CLI, immutable local YAML storage, and a rebuildable
+ SQLite projection. The approver acted as Human Authority and analyst reviewer
+ after reviewing the plan and passing npm run check results.
+issued_at: 2026-07-17T19:32:16Z
+effective_at: 2026-07-17T19:32:16Z
+expires_at: 2026-07-24T19:32:16Z
+review_at: 2026-07-21T19:32:16Z
+revoked: false
+revocation_reference: null
diff --git a/schemas/records/approval-record.schema.json b/schemas/records/approval-record.schema.json
index d1b7390..9d67283 100644
--- a/schemas/records/approval-record.schema.json
+++ b/schemas/records/approval-record.schema.json
@@ -26,7 +26,7 @@
"required": ["cash_usd", "labor_hours", "duration_days", "data_classes", "risk_level"],
"properties": {
"cash_usd": { "type": "number", "minimum": 0 }, "labor_hours": { "type": "number", "minimum": 0 },
- "duration_days": { "type": "integer", "minimum": 0 }, "data_classes": { "$ref": "#/$defs/nonemptyRefs" },
+ "duration_days": { "type": "integer", "minimum": 0 }, "data_classes": { "$ref": "#/$defs/dataClasses" },
"risk_level": { "enum": ["low", "medium", "high", "critical"] }
}
},
@@ -44,6 +44,7 @@
"dateTime": { "type": "string", "format": "date-time" }, "nonempty": { "type": "string", "minLength": 1 },
"refs": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/nonempty" } },
"nonemptyRefs": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/nonempty" } },
+ "dataClasses": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/privacy" } },
"privacy": { "enum": ["public", "internal", "confidential", "restricted"] }
}
}
diff --git a/schemas/records/decision-record.schema.json b/schemas/records/decision-record.schema.json
index 4335f46..797a5c1 100644
--- a/schemas/records/decision-record.schema.json
+++ b/schemas/records/decision-record.schema.json
@@ -2,7 +2,7 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://genesis.local/schemas/records/decision-record.schema.json",
"type": "object", "additionalProperties": false,
- "required": ["id", "record_type", "schema_version", "policy_version", "created_at", "updated_at", "owner", "affected_business", "status", "evidence_references", "related_records", "privacy_classification", "immutable_history_refs", "problem", "hypothesis", "confidence", "evidence", "counterevidence", "alternatives", "expected_outcome", "metric", "decision", "decision_date", "review_date", "actual_outcome", "confidence_update"],
+ "required": ["id", "record_type", "schema_version", "policy_version", "created_at", "updated_at", "owner", "affected_business", "status", "evidence_references", "related_records", "privacy_classification", "immutable_history_refs", "target_customer", "problem", "hypothesis", "confidence", "evidence", "counterevidence", "alternatives", "expected_outcome", "metric", "decision", "decision_date", "review_date", "actual_outcome", "confidence_update"],
"properties": {
"id": { "$ref": "#/$defs/id" }, "record_type": { "const": "decision_record" },
"schema_version": { "$ref": "#/$defs/semver" }, "policy_version": { "const": "2.0.0" },
@@ -11,7 +11,8 @@
"status": { "enum": ["draft", "active", "closed", "superseded"] },
"evidence_references": { "$ref": "#/$defs/nonemptyRefs" }, "related_records": { "$ref": "#/$defs/recordRefs" },
"privacy_classification": { "$ref": "#/$defs/privacy" }, "immutable_history_refs": { "$ref": "#/$defs/nonemptyRefs" },
- "example_only": { "type": "boolean" }, "problem": { "$ref": "#/$defs/nonempty" }, "hypothesis": { "$ref": "#/$defs/nonempty" },
+ "example_only": { "type": "boolean" }, "target_customer": { "$ref": "#/$defs/nonempty" },
+ "problem": { "$ref": "#/$defs/nonempty" }, "hypothesis": { "$ref": "#/$defs/nonempty" },
"confidence": { "$ref": "#/$defs/confidence" }, "evidence": { "$ref": "#/$defs/nonemptyRefs" },
"counterevidence": { "$ref": "#/$defs/refs" }, "alternatives": { "$ref": "#/$defs/nonemptyRefs" },
"expected_outcome": { "$ref": "#/$defs/nonempty" }, "metric": { "$ref": "#/$defs/nonempty" },
diff --git a/schemas/records/experiment-record.schema.json b/schemas/records/experiment-record.schema.json
index 834952b..1eaf76b 100644
--- a/schemas/records/experiment-record.schema.json
+++ b/schemas/records/experiment-record.schema.json
@@ -2,13 +2,14 @@
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://genesis.local/schemas/records/experiment-record.schema.json",
"type": "object", "additionalProperties": false,
- "required": ["id", "record_type", "schema_version", "policy_version", "created_at", "updated_at", "owner", "affected_business", "status", "evidence_references", "related_records", "privacy_classification", "immutable_history_refs", "problem", "supported_decision", "hypothesis", "confidence", "evidence", "counterevidence", "baseline", "comparison_method", "metric", "expected_outcome", "minimum_meaningful_effect", "failure_conditions", "stop_conditions", "limits", "decision_date", "allowed_outcomes", "approval_references", "actual_cost", "results", "reflection", "outcome", "experience_reference"],
+ "required": ["id", "record_type", "schema_version", "policy_version", "created_at", "updated_at", "owner", "affected_business", "status", "evidence_references", "related_records", "privacy_classification", "immutable_history_refs", "subtype", "validation_outcome", "problem", "supported_decision", "hypothesis", "confidence", "evidence", "counterevidence", "baseline", "comparison_method", "metric", "expected_outcome", "minimum_meaningful_effect", "failure_conditions", "stop_conditions", "limits", "decision_date", "allowed_outcomes", "approval_references"],
"properties": {
"id": { "$ref": "#/$defs/id" }, "record_type": { "const": "experiment_record" },
"schema_version": { "$ref": "#/$defs/semver" }, "policy_version": { "const": "2.0.0" },
"created_at": { "$ref": "#/$defs/dateTime" }, "updated_at": { "$ref": "#/$defs/dateTime" },
"owner": { "$ref": "#/$defs/nonempty" }, "affected_business": { "$ref": "#/$defs/nonempty" },
"status": { "enum": ["draft", "active", "closed", "superseded"] },
+ "subtype": { "enum": ["validation"] }, "validation_outcome": { "enum": ["pending", "passed", "failed"] },
"evidence_references": { "$ref": "#/$defs/nonemptyRefs" }, "related_records": { "$ref": "#/$defs/recordRefs" },
"privacy_classification": { "$ref": "#/$defs/privacy" }, "immutable_history_refs": { "$ref": "#/$defs/nonemptyRefs" },
"example_only": { "type": "boolean" }, "problem": { "$ref": "#/$defs/nonempty" }, "supported_decision": { "$ref": "#/$defs/recordRef" },
@@ -23,18 +24,42 @@
"failure_conditions": { "$ref": "#/$defs/nonemptyRefs" }, "stop_conditions": { "$ref": "#/$defs/nonemptyRefs" },
"limits": {
"type": "object", "additionalProperties": false, "required": ["cash_usd", "labor_hours", "duration_days", "data_classes", "risk_level"],
- "properties": { "cash_usd": { "type": "number", "minimum": 0 }, "labor_hours": { "type": "number", "minimum": 0 }, "duration_days": { "type": "integer", "minimum": 0 }, "data_classes": { "$ref": "#/$defs/nonemptyRefs" }, "risk_level": { "enum": ["low", "medium", "high", "critical"] } }
+ "properties": { "cash_usd": { "type": "number", "minimum": 0 }, "labor_hours": { "type": "number", "minimum": 0 }, "duration_days": { "type": "integer", "minimum": 0 }, "data_classes": { "$ref": "#/$defs/experimentDataClasses" }, "risk_level": { "enum": ["low", "medium", "high", "critical"] } }
},
"decision_date": { "$ref": "#/$defs/dateTime" },
"allowed_outcomes": { "const": ["scale", "pivot", "learning_lab", "archive", "kill"] },
- "approval_references": { "$ref": "#/$defs/nonemptyRecordRefs" },
+ "approval_references": { "$ref": "#/$defs/recordRefs" },
"actual_cost": {
"type": "object", "additionalProperties": false, "required": ["cash_usd", "labor_hours"],
"properties": { "cash_usd": { "type": "number", "minimum": 0 }, "labor_hours": { "type": "number", "minimum": 0 } }
},
"results": { "$ref": "#/$defs/nonempty" }, "reflection": { "$ref": "#/$defs/nonempty" },
- "outcome": { "enum": ["scale", "pivot", "learning_lab", "archive", "kill"] }, "experience_reference": { "$ref": "#/$defs/recordRef" }
+ "outcome": { "enum": ["scale", "pivot", "learning_lab", "archive", "kill"] }, "experience_reference": { "$ref": "#/$defs/recordRef" },
+ "confidence_update": { "type": ["number", "null"], "minimum": 0, "maximum": 1 },
+ "decision_outcome": { "type": ["string", "null"], "enum": ["scale", "pivot", "learning_lab", "archive", "kill", null] }
},
+ "allOf": [
+ {
+ "if": { "properties": { "status": { "enum": ["active", "closed"] } }, "required": ["status"] },
+ "then": { "properties": { "approval_references": { "$ref": "#/$defs/nonemptyRecordRefs" } } }
+ },
+ {
+ "if": { "properties": { "status": { "const": "closed" } }, "required": ["status"] },
+ "then": {
+ "required": ["actual_cost", "results", "reflection", "outcome", "experience_reference", "confidence_update", "decision_outcome"],
+ "properties": {
+ "actual_cost": true,
+ "results": true,
+ "reflection": true,
+ "outcome": true,
+ "experience_reference": true,
+ "confidence_update": true,
+ "decision_outcome": true,
+ "validation_outcome": { "enum": ["passed", "failed"] }
+ }
+ }
+ }
+ ],
"$defs": {
"id": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" }, "recordRef": { "type": "string", "pattern": "^[a-z0-9][a-z0-9-]*$" },
"semver": { "type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$" }, "dateTime": { "type": "string", "format": "date-time" },
@@ -42,6 +67,7 @@
"recordRefs": { "type": "array", "uniqueItems": true, "items": { "$ref": "#/$defs/recordRef" } },
"nonemptyRecordRefs": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/recordRef" } },
"nonemptyRefs": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/nonempty" } },
+ "experimentDataClasses": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["public", "internal", "confidential"] } },
"privacy": { "enum": ["public", "internal", "confidential", "restricted"] }
}
}
diff --git a/schemas/runtime/evidence-entry.schema.json b/schemas/runtime/evidence-entry.schema.json
new file mode 100644
index 0000000..8c3d63e
--- /dev/null
+++ b/schemas/runtime/evidence-entry.schema.json
@@ -0,0 +1,38 @@
+{
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
+ "$id": "https://genesis.local/schemas/runtime/evidence-entry.schema.json",
+ "type": "object",
+ "additionalProperties": false,
+ "required": [
+ "id",
+ "business_id",
+ "collected_at",
+ "source_reference",
+ "summary",
+ "stance",
+ "provenance",
+ "privacy_classification"
+ ],
+ "properties": {
+ "id": { "$ref": "#/$defs/id" },
+ "business_id": { "$ref": "#/$defs/id" },
+ "collected_at": { "type": "string", "format": "date-time" },
+ "source_reference": { "$ref": "#/$defs/nonempty" },
+ "summary": { "$ref": "#/$defs/nonempty" },
+ "stance": { "enum": ["support", "contradict"] },
+ "provenance": { "$ref": "#/$defs/nonempty" },
+ "privacy_classification": {
+ "enum": ["public", "internal", "confidential", "restricted"]
+ }
+ },
+ "$defs": {
+ "id": {
+ "type": "string",
+ "pattern": "^[a-z0-9][a-z0-9-]*$"
+ },
+ "nonempty": {
+ "type": "string",
+ "minLength": 1
+ }
+ }
+}
diff --git a/schemas/workflow.schema.json b/schemas/workflow.schema.json
index 27aae96..a748586 100644
--- a/schemas/workflow.schema.json
+++ b/schemas/workflow.schema.json
@@ -23,13 +23,13 @@
"type": "array",
"minItems": 1,
"uniqueItems": true,
- "items": { "$ref": "#/$defs/identifier" }
+ "items": { "$ref": "#/$defs/fieldPath" }
},
"closure_required_fields": {
"type": "array",
"minItems": 1,
"uniqueItems": true,
- "items": { "$ref": "#/$defs/identifier" }
+ "items": { "$ref": "#/$defs/fieldPath" }
},
"learning_prototype_exception": {
"type": "object",
@@ -64,6 +64,7 @@
],
"$defs": {
"identifier": { "type": "string", "pattern": "^[a-z][a-z0-9_]*$" },
+ "fieldPath": { "type": "string", "pattern": "^[a-z][a-z0-9_]*(?:\\.[a-z][a-z0-9_]*)*$" },
"nonemptyStrings": {
"type": "array",
"minItems": 1,
diff --git a/scripts/validate-genesis.mjs b/scripts/validate-genesis.mjs
index 19374ca..053605f 100644
--- a/scripts/validate-genesis.mjs
+++ b/scripts/validate-genesis.mjs
@@ -470,15 +470,70 @@ function workflowState(workflow, stateId) {
return workflow?.states?.find((state) => state.id === stateId);
}
-function validApproval(approvals, approver, action) {
- return typeof approver === "string" && Array.isArray(approvals) && approvals.some((approval) => (
- approval
- && approval.approver === approver
- && approval.valid === true
- && (action === undefined || approval.action === action)
+function matchingApproval(approvals, { approverRole, action, actor, now, limits }) {
+ return Array.isArray(approvals) && approvals.some((approval) => (
+ approval?.approver_role === approverRole
+ && validateApproval(approval, { now, action, actor, limits }).length === 0
));
}
+const APPROVAL_LIMIT_FIELDS = [
+ "cash_usd", "labor_hours", "duration_days", "data_classes", "risk_level",
+];
+const DATA_CLASSES = new Set(["public", "internal", "confidential", "restricted"]);
+const RISK_LEVELS = ["low", "medium", "high", "critical"];
+
+function completeBoundedLimits(limits) {
+ return limits !== null
+ && typeof limits === "object"
+ && !Array.isArray(limits)
+ && APPROVAL_LIMIT_FIELDS.every((field) => Object.hasOwn(limits, field))
+ && Object.keys(limits).every((field) => APPROVAL_LIMIT_FIELDS.includes(field))
+ && Number.isFinite(limits.cash_usd)
+ && limits.cash_usd >= 0
+ && Number.isFinite(limits.labor_hours)
+ && limits.labor_hours >= 0
+ && Number.isInteger(limits.duration_days)
+ && limits.duration_days >= 0
+ && Array.isArray(limits.data_classes)
+ && limits.data_classes.length > 0
+ && new Set(limits.data_classes).size === limits.data_classes.length
+ && limits.data_classes.every((dataClass) => DATA_CLASSES.has(dataClass))
+ && RISK_LEVELS.includes(limits.risk_level);
+}
+
+function recordPathValue(record, fieldPath) {
+ return fieldPath.split(".").reduce((value, field) => value?.[field], record);
+}
+
+function hasRecordValue(record, fieldPath) {
+ const value = recordPathValue(record, fieldPath);
+ if (value === null || value === undefined) {
+ return false;
+ }
+ if (typeof value === "string") {
+ return value.trim().length > 0;
+ }
+ if (Array.isArray(value)) {
+ return value.length > 0;
+ }
+ return true;
+}
+
+function validatesRecordSchema(policySet, recordId, record) {
+ const schemaPath = policySet.schemaFiles.record_templates.get(recordId);
+ if (!schemaPath || !record || typeof record !== "object") {
+ return false;
+ }
+ try {
+ const ajv = new Ajv2020({ allErrors: true, strict: true });
+ addFormats(ajv);
+ return ajv.compile(loadJsonFile(schemaPath))(record);
+ } catch {
+ return false;
+ }
+}
+
export function validateTransition(policySet, workflowId, from, to, context = {}) {
const errors = [];
const workflow = policySet.policies.get(workflowId);
@@ -498,22 +553,24 @@ export function validateTransition(policySet, workflowId, from, to, context = {}
}
if (workflowId === "business_lifecycle" && from === "validate" && to === "build") {
- const passedValidation = Array.isArray(context.recordTypes)
- && context.recordTypes.some((record) => (
- record
- && typeof record === "object"
- && record.id === "experiment_record"
- && record.subtype === "validation"
- && record.status === "passed"
- ));
+ const passedValidation = context.records?.some((record) => (
+ validatesRecordSchema(policySet, "experiment_record", record)
+ && record.record_type === "experiment_record"
+ && record.subtype === "validation"
+ && record.status === "closed"
+ && record.validation_outcome === "passed"
+ ));
const prototype = context.learningPrototype;
const now = Date.parse(context.now ?? new Date().toISOString());
const expiry = Date.parse(prototype?.expiresAt);
- const validException = validApproval(
- context.approvals,
- "human_authority",
- "learning_prototype_exception",
- )
+ const validException = completeBoundedLimits(prototype?.limits)
+ && matchingApproval(context.approvals, {
+ approverRole: "human_authority",
+ action: "learning_prototype_exception",
+ actor: context.actor,
+ now: context.now,
+ limits: prototype?.limits,
+ })
&& prototype?.budgetCapped === true
&& prototype?.nonProduction === true
&& Number.isFinite(expiry)
@@ -534,7 +591,12 @@ export function validateTransition(policySet, workflowId, from, to, context = {}
workflowId === "business_lifecycle"
&& from === "build"
&& to === "launch"
- && !validApproval(context.approvals, "human_authority", "launch")
+ && !matchingApproval(context.approvals, {
+ approverRole: "human_authority",
+ action: "launch",
+ actor: context.actor,
+ now: context.now,
+ })
) {
errors.push(issue(
"LAUNCH_HUMAN_APPROVAL_REQUIRED",
@@ -545,12 +607,20 @@ export function validateTransition(policySet, workflowId, from, to, context = {}
}
if (workflowId === "experiment_lifecycle" && from === "approval" && to === "running") {
+ const experimentRecord = context.experimentRecord;
const requiredFields = workflow.preregistration_required_fields ?? [];
- const suppliedFields = new Set(context.preregistrationFields ?? []);
- const missingFields = requiredFields.filter((field) => !suppliedFields.has(field));
+ const missingFields = requiredFields.filter((field) => !hasRecordValue(experimentRecord, field));
const requiredApprover = policySet.policies
.get("decision_policy")?.classes?.[fromState.approval_class]?.required_approver;
- if (missingFields.length > 0 || !validApproval(context.approvals, requiredApprover, "experiment")) {
+ const recordValid = validatesRecordSchema(policySet, "experiment_record", experimentRecord);
+ const approvalValid = matchingApproval(context.approvals, {
+ approverRole: requiredApprover,
+ action: "experiment",
+ actor: context.actor,
+ now: context.now,
+ limits: experimentRecord?.limits,
+ });
+ if (missingFields.length > 0 || !recordValid || !approvalValid) {
errors.push(issue(
"EXPERIMENT_PREREGISTRATION_INCOMPLETE",
source,
@@ -561,15 +631,18 @@ export function validateTransition(policySet, workflowId, from, to, context = {}
}
if (workflowId === "experiment_lifecycle" && from === "decision" && to === "closed") {
- const suppliedFields = new Set(context.closureFields ?? []);
+ const experimentRecord = context.experimentRecord;
const missingFields = (workflow.closure_required_fields ?? [])
- .filter((field) => !suppliedFields.has(field));
- if (missingFields.length > 0) {
+ .filter((field) => !hasRecordValue(experimentRecord, field));
+ const canonicalClosedRecord = experimentRecord?.record_type === "experiment_record"
+ && experimentRecord.status === "closed"
+ && validatesRecordSchema(policySet, "experiment_record", experimentRecord);
+ if (missingFields.length > 0 || !canonicalClosedRecord) {
errors.push(issue(
"WORKFLOW_TRANSITION_INVALID",
source,
`/states/${fromIndex}/next_states`,
- `experiment closure is incomplete; missing: ${missingFields.join(", ")}`,
+ `experiment closure is incomplete; missing: ${missingFields.join(", ") || "none"}`,
));
}
}
@@ -691,10 +764,10 @@ export function validateWorkflows(policySet) {
if (workflowId === "experiment_lifecycle") {
const required = [
"problem", "supported_decision", "hypothesis", "confidence", "evidence",
- "counterevidence", "baseline", "comparison_method", "metric_formula",
- "metric_population", "metric_denominator", "metric_data_source", "expected_outcome",
- "minimum_meaningful_effect", "failure_conditions", "stop_conditions", "maximum_cash",
- "maximum_labor", "maximum_duration", "maximum_data", "maximum_risk", "owner",
+ "counterevidence", "baseline", "comparison_method", "metric.formula",
+ "metric.population", "metric.denominator", "metric.data_source", "expected_outcome",
+ "minimum_meaningful_effect", "failure_conditions", "stop_conditions", "limits.cash_usd",
+ "limits.labor_hours", "limits.duration_days", "limits.data_classes", "limits.risk_level", "owner",
"decision_date", "allowed_outcomes",
];
const configured = new Set(workflow.preregistration_required_fields ?? []);
@@ -709,7 +782,7 @@ export function validateWorkflows(policySet) {
}
const requiredClosureFields = [
"actual_cost", "outcome", "reflection", "confidence_update", "decision_outcome",
- "linked_experience_record",
+ "experience_reference",
];
const configuredClosureFields = new Set(workflow.closure_required_fields ?? []);
const missingClosureFields = requiredClosureFields
@@ -909,7 +982,79 @@ function parseApprovalDate(value) {
return Date.parse(value);
}
-export function validateApproval(record, { now, action, actor }) {
+let approvalRecordValidator;
+
+function validateCanonicalApprovalRecord(record) {
+ if (!approvalRecordValidator) {
+ const ajv = new Ajv2020({ allErrors: true, strict: true });
+ addFormats(ajv);
+ approvalRecordValidator = ajv.compile(loadJsonFile(
+ new URL("../schemas/records/approval-record.schema.json", import.meta.url),
+ ));
+ }
+ return approvalRecordValidator(record);
+}
+
+function approvalSchemaErrorPath(error) {
+ if (error?.keyword === "required") {
+ return `${error.instancePath}/${error.params.missingProperty}`;
+ }
+ return error?.instancePath || "/";
+}
+
+function approvalLimitsExceeded(approved, requested) {
+ if (
+ !Array.isArray(approved?.data_classes)
+ || approved.data_classes.length === 0
+ || approved.data_classes.some((dataClass) => !DATA_CLASSES.has(dataClass))
+ ) {
+ return true;
+ }
+ if (requested === undefined) {
+ return false;
+ }
+ if (!requested || typeof requested !== "object" || Array.isArray(requested)) {
+ return true;
+ }
+
+ const knownFields = new Set(APPROVAL_LIMIT_FIELDS);
+ if (Object.keys(requested).some((field) => !knownFields.has(field))) {
+ return true;
+ }
+
+ for (const field of ["cash_usd", "labor_hours", "duration_days"]) {
+ if (requested[field] !== undefined && (
+ !Number.isFinite(requested[field])
+ || requested[field] < 0
+ || !Number.isFinite(approved?.[field])
+ || requested[field] > approved[field]
+ )) {
+ return true;
+ }
+ }
+
+ if (requested.data_classes !== undefined && (
+ !Array.isArray(requested.data_classes)
+ || !Array.isArray(approved?.data_classes)
+ || requested.data_classes.some((dataClass) => (
+ !DATA_CLASSES.has(dataClass) || !approved.data_classes.includes(dataClass)
+ ))
+ )) {
+ return true;
+ }
+
+ if (requested.risk_level !== undefined) {
+ const requestedRisk = RISK_LEVELS.indexOf(requested.risk_level);
+ const approvedRisk = RISK_LEVELS.indexOf(approved?.risk_level);
+ if (requestedRisk < 0 || approvedRisk < 0 || requestedRisk > approvedRisk) {
+ return true;
+ }
+ }
+
+ return false;
+}
+
+export function validateApproval(record, { now, action, actor, limits }) {
const errors = [];
if (record?.revoked === true) {
@@ -920,6 +1065,23 @@ export function validateApproval(record, { now, action, actor }) {
));
}
+ if (!validateCanonicalApprovalRecord(record)) {
+ const schemaError = approvalRecordValidator.errors?.[0];
+ errors.push(approvalIssue(
+ "APPROVAL_RECORD_INVALID",
+ approvalSchemaErrorPath(schemaError),
+ "approval must match the canonical Approval Record schema",
+ ));
+ }
+
+ if (record?.status !== "active") {
+ errors.push(approvalIssue(
+ "APPROVAL_STATUS_INVALID",
+ "/status",
+ "approval status must be active",
+ ));
+ }
+
const times = {
issued: parseApprovalDate(record?.issued_at),
effective: parseApprovalDate(record?.effective_at),
@@ -967,6 +1129,14 @@ export function validateApproval(record, { now, action, actor }) {
));
}
+ if (approvalLimitsExceeded(record?.limits, limits)) {
+ errors.push(approvalIssue(
+ "APPROVAL_LIMIT_MISMATCH",
+ "/limits",
+ "requested limits exceed or do not match the approved envelope",
+ ));
+ }
+
return errors;
}
diff --git a/src/application/genesis-service.mjs b/src/application/genesis-service.mjs
new file mode 100644
index 0000000..3f6237f
--- /dev/null
+++ b/src/application/genesis-service.mjs
@@ -0,0 +1,524 @@
+import fs from "node:fs";
+import path from "node:path";
+
+import { buildDecisionRecord, buildEvidenceEntry, buildExperimentRecord, versionDecisionRecord } from "../core/record-builders.mjs";
+import { evaluateDiscoverGate, buildStatus } from "../core/discovery-workflow.mjs";
+import { GenesisError } from "../core/errors.mjs";
+import { normalizeBusinessId } from "../core/ids.mjs";
+import { createSchemaRegistry } from "../core/schema-registry.mjs";
+import { listRecords, readRecord, writeRecords } from "../storage/yaml-record-store.mjs";
+import { openProjection, projectRecord, projectionConsistency, recordBlockedCommand, rebuildProjection } from "../storage/projection.mjs";
+import { withWorkspaceLock, workspacePaths } from "../storage/workspace.mjs";
+
+const DEFAULT_CONFIRM = async () => true;
+const DEFAULT_CLOCK = () => new Date();
+
+function unique(values) {
+ return [...new Set(values.filter((value) => value !== undefined && value !== null))];
+}
+
+function toRecordEntries(projectRoot) {
+ return listRecords(projectRoot).map((descriptor) => ({
+ descriptor,
+ record: readRecord(descriptor.absolutePath),
+ }));
+}
+
+function latestByVersion(entries) {
+ return [...entries].sort((left, right) => left.descriptor.version - right.descriptor.version).at(-1) ?? null;
+}
+
+function businessEntries(projectRoot, businessId) {
+ const normalized = normalizeBusinessId(businessId);
+ const entries = toRecordEntries(projectRoot).filter(({ descriptor, record }) => (
+ (descriptor.kind === "decision" && record.affected_business === normalized)
+ || (descriptor.kind === "experiment" && record.affected_business === normalized)
+ || (descriptor.kind === "evidence" && record.business_id === normalized)
+ ));
+
+ return {
+ entries,
+ decisionEntries: entries.filter(({ descriptor }) => descriptor.kind === "decision"),
+ experimentEntries: entries.filter(({ descriptor }) => descriptor.kind === "experiment"),
+ evidenceEntries: entries.filter(({ descriptor }) => descriptor.kind === "evidence"),
+ };
+}
+
+function ensureBusinessExists(decisionEntries, businessId) {
+ if (decisionEntries.length === 0) {
+ throw new GenesisError("BUSINESS_NOT_FOUND", "Business opportunity does not exist", {
+ path: "/business_id",
+ correction: "Start the business opportunity before adding evidence or planning an experiment",
+ escalation: "builder",
+ });
+ }
+}
+
+function existingExperimentOrThrow(experimentEntries) {
+ if (experimentEntries.length > 0) {
+ throw new GenesisError("COMMAND_UNAVAILABLE", "No command is available once approval pending exists", {
+ path: "/next_command",
+ correction: "Use status or rebuild-index; version one does not progress beyond approval_pending",
+ escalation: "builder",
+ });
+ }
+}
+
+function projectionIssue(cause) {
+ return new GenesisError("PROJECTION_STALE", "Canonical YAML is safe but SQLite is stale", {
+ path: "/projection_consistent",
+ correction: "Run genesis rebuild-index",
+ escalation: "builder",
+ cause,
+ });
+}
+
+function latestDescriptor(entries) {
+ return latestByVersion(entries);
+}
+
+function latestRecord(entries) {
+ return latestDescriptor(entries)?.record ?? null;
+}
+
+function latestDecisionEntry(entries) {
+ return latestDescriptor(entries.filter(({ descriptor }) => descriptor.kind === "decision"));
+}
+
+function latestExperimentEntry(entries) {
+ return latestDescriptor(entries.filter(({ descriptor }) => descriptor.kind === "experiment"));
+}
+
+function evidenceSources(evidenceEntries) {
+ return evidenceEntries.map(({ record }) => record.source_reference).filter(Boolean);
+}
+
+function evidenceIds(evidenceEntries) {
+ return evidenceEntries.map(({ record }) => record.id).filter(Boolean);
+}
+
+function currentStatus({ projectRoot, businessId, now }) {
+ const normalized = normalizeBusinessId(businessId);
+ const { entries, decisionEntries, experimentEntries, evidenceEntries } = businessEntries(projectRoot, normalized);
+ ensureBusinessExists(decisionEntries, normalized);
+
+ const latestDecision = latestDecisionEntry(entries);
+ const latestExperiment = latestExperimentEntry(entries);
+ const paths = workspacePaths(projectRoot);
+ let consistency = { consistent: entries.length === 0, yamlCount: entries.length, projectedCount: 0 };
+ let blockedCommands = [];
+
+ if (fs.existsSync(paths.db)) {
+ const db = openProjection(paths.db);
+ try {
+ consistency = projectionConsistency(db, listRecords(projectRoot));
+ blockedCommands = db.prepare(
+ "SELECT code FROM blocked_commands WHERE business_id = ? ORDER BY id",
+ ).all(normalized);
+ } finally {
+ db.close();
+ }
+ }
+
+ const status = buildStatus({
+ decisionVersions: decisionEntries.map(({ record }) => record),
+ experimentVersions: experimentEntries.map(({ record }) => record),
+ evidence: evidenceEntries.map(({ record }) => record),
+ blockedCommands,
+ consistency,
+ now,
+ });
+
+ return {
+ business_id: normalized,
+ latest_decision_version: latestDecision?.descriptor.version ?? null,
+ latest_experiment_version: latestExperiment?.descriptor.version ?? null,
+ latest_decision_path: latestDecision?.descriptor.relativePath ?? null,
+ latest_experiment_path: latestExperiment?.descriptor.relativePath ?? null,
+ limits: latestRecord(experimentEntries)?.limits ?? null,
+ ...status,
+ };
+}
+
+function verifyProjectionReference(db, relativePath) {
+ const row = db.prepare("SELECT 1 AS present FROM record_versions WHERE relative_path = ?").get(relativePath);
+ if (!row) {
+ throw new Error(`projection missing record reference: ${relativePath}`);
+ }
+}
+
+function projectWrittenRecords({ projectRoot, registry, written }) {
+ const dbPath = workspacePaths(projectRoot).db;
+ const db = openProjection(dbPath);
+ try {
+ for (const item of written) {
+ projectRecord(db, {
+ kind: item.kind,
+ id: item.record.id,
+ version: item.version,
+ relativePath: item.relativePath,
+ }, item.record);
+ verifyProjectionReference(db, item.relativePath);
+ }
+
+ const consistency = projectionConsistency(db, listRecords(projectRoot));
+ if (!consistency.consistent) {
+ throw new Error("projection row count mismatch");
+ }
+
+ return { projection_stale: false, warning: null };
+ } catch (cause) {
+ return {
+ projection_stale: true,
+ warning: projectionIssue(cause),
+ };
+ } finally {
+ db.close();
+ }
+}
+
+function proposalCancelled() {
+ return { changed: false, reason: "cancelled" };
+}
+
+function startBusinessProposal(input, clock, registry) {
+ const businessId = normalizeBusinessId(input.business_id);
+ const now = clock();
+ const evidenceId = `${businessId}-evidence-001`;
+ const sourceReferences = unique(input.evidence_references ?? [input.source_reference]);
+ const evidenceRecord = buildEvidenceEntry({
+ id: evidenceId,
+ business_id: businessId,
+ source_reference: input.source_reference,
+ summary: input.summary,
+ stance: input.stance,
+ provenance: input.provenance,
+ privacy_classification: input.privacy_classification,
+ }, clock, { registry });
+
+ const decisionRecord = buildDecisionRecord({
+ business_id: businessId,
+ owner: input.owner,
+ evidence_references: sourceReferences,
+ related_records: input.related_records ?? [evidenceId],
+ immutable_history_refs: [`records/decisions/${businessId}-decision.v0001.yaml`],
+ target_customer: input.target_customer,
+ problem: input.problem,
+ hypothesis: input.hypothesis,
+ confidence: input.confidence,
+ evidence: sourceReferences,
+ counterevidence: input.counterevidence ?? [],
+ alternatives: input.alternatives,
+ expected_outcome: input.expected_outcome,
+ metric: input.metric,
+ decision: input.decision,
+ review_date: input.review_date,
+ privacy_classification: input.privacy_classification,
+ }, clock, { registry });
+
+ return {
+ command: "start-business",
+ business_id: businessId,
+ state: "discover",
+ records: [
+ { kind: "evidence", record: evidenceRecord, version: 1 },
+ { kind: "decision", record: decisionRecord, version: 1 },
+ ],
+ };
+}
+
+function addEvidenceProposal(projectRoot, businessId, input, clock, registry) {
+ const normalized = normalizeBusinessId(businessId);
+ const { entries, decisionEntries, experimentEntries, evidenceEntries } = businessEntries(projectRoot, normalized);
+ ensureBusinessExists(decisionEntries, normalized);
+ existingExperimentOrThrow(experimentEntries);
+
+ const latestDecision = latestDecisionEntry(entries);
+ const evidenceVersion = evidenceEntries.length + 1;
+ const evidenceId = `${normalized}-evidence-${String(evidenceVersion).padStart(3, "0")}`;
+ const evidenceRecord = buildEvidenceEntry({
+ id: evidenceId,
+ business_id: normalized,
+ source_reference: input.source_reference,
+ summary: input.summary,
+ stance: input.stance,
+ provenance: input.provenance,
+ privacy_classification: input.privacy_classification,
+ }, clock, { registry });
+
+ const currentDecision = latestDecision.record;
+ const nextEvidenceReferences = unique([
+ ...(currentDecision.evidence_references ?? []),
+ evidenceRecord.source_reference,
+ ]);
+ const nextEvidence = unique([
+ ...(currentDecision.evidence ?? []),
+ evidenceRecord.source_reference,
+ ]);
+ const nextCounterevidence = evidenceRecord.stance === "contradict"
+ ? unique([
+ ...(currentDecision.counterevidence ?? []),
+ evidenceRecord.source_reference,
+ ])
+ : currentDecision.counterevidence ?? [];
+ const nextRelatedRecords = unique([
+ ...(currentDecision.related_records ?? []),
+ evidenceRecord.id,
+ ]);
+
+ const decisionRecord = versionDecisionRecord(
+ currentDecision,
+ {
+ ...input.decision_changes,
+ evidence_references: nextEvidenceReferences,
+ evidence: nextEvidence,
+ counterevidence: nextCounterevidence,
+ related_records: nextRelatedRecords,
+ },
+ currentDecision.immutable_history_refs?.[0] ?? latestDecision.descriptor.relativePath,
+ clock,
+ { registry },
+ );
+
+ return {
+ command: "add-evidence",
+ business_id: normalized,
+ state: "discover",
+ records: [
+ { kind: "evidence", record: evidenceRecord, version: evidenceVersion },
+ {
+ kind: "decision",
+ record: decisionRecord,
+ version: latestDecision.descriptor.version + 1,
+ },
+ ],
+ };
+}
+
+function planExperimentProposal(projectRoot, businessId, input, clock, registry) {
+ const normalized = normalizeBusinessId(businessId);
+ const { entries, decisionEntries, experimentEntries, evidenceEntries } = businessEntries(projectRoot, normalized);
+ ensureBusinessExists(decisionEntries, normalized);
+ existingExperimentOrThrow(experimentEntries);
+
+ const latestDecision = latestDecisionEntry(entries);
+ const discoverGate = evaluateDiscoverGate({
+ decision: latestDecision.record,
+ evidence: evidenceEntries.map(({ record }) => record),
+ });
+ if (!discoverGate.passed) {
+ const dbPath = workspacePaths(projectRoot).db;
+ const db = openProjection(dbPath);
+ try {
+ recordBlockedCommand(db, {
+ businessId: normalized,
+ command: "plan-experiment",
+ code: "DISCOVER_GATE_BLOCKED",
+ occurredAt: clock().toISOString(),
+ });
+ } finally {
+ db.close();
+ }
+
+ const blocker = discoverGate.blockers[0];
+ throw new GenesisError("DISCOVER_GATE_BLOCKED", "Discover gate blocked", {
+ path: blocker?.path ?? "/discover_gate",
+ correction: blocker?.correction ?? "Complete the Discover gate before planning an experiment",
+ escalation: blocker?.escalation ?? "builder",
+ });
+ }
+
+ const supportingEvidence = evidenceEntries.filter(({ record }) => record.stance === "support");
+ const contradictingEvidence = evidenceEntries.filter(({ record }) => record.stance === "contradict");
+ const evidenceReferences = unique([
+ ...evidenceSources(evidenceEntries),
+ ...(latestDecision.record.evidence_references ?? []),
+ ]);
+ const relatedRecords = unique([
+ latestDecision.record.id,
+ ...evidenceIds(evidenceEntries),
+ ...(latestDecision.record.related_records ?? []),
+ ]);
+
+ const experimentRecord = buildExperimentRecord({
+ business_id: normalized,
+ owner: input.owner ?? latestDecision.record.owner,
+ evidence_references: evidenceReferences,
+ related_records: relatedRecords,
+ problem: input.problem ?? latestDecision.record.problem,
+ supported_decision: input.supported_decision ?? latestDecision.record.id,
+ hypothesis: input.hypothesis ?? latestDecision.record.hypothesis,
+ confidence: input.confidence ?? latestDecision.record.confidence,
+ evidence: input.evidence ?? supportingEvidence.map(({ record }) => record.source_reference),
+ counterevidence: input.counterevidence ?? contradictingEvidence.map(({ record }) => record.source_reference),
+ baseline: input.baseline,
+ comparison_method: input.comparison_method,
+ metric: input.metric,
+ expected_outcome: input.expected_outcome,
+ minimum_meaningful_effect: input.minimum_meaningful_effect,
+ failure_conditions: input.failure_conditions,
+ stop_conditions: input.stop_conditions,
+ limits: input.limits,
+ decision_date: input.decision_date,
+ allowed_outcomes: input.allowed_outcomes,
+ privacy_classification: input.privacy_classification ?? latestDecision.record.privacy_classification,
+ }, clock, { registry });
+
+ return {
+ command: "plan-experiment",
+ business_id: normalized,
+ state: "approval_pending",
+ record: experimentRecord,
+ records: [
+ { kind: "experiment", record: experimentRecord, version: 1 },
+ ],
+ };
+}
+
+async function persistCommand({ projectRoot, registry, proposal, projectRecords = projectWrittenRecords }) {
+ const written = [];
+ const items = proposal.records ?? (proposal.record ? [
+ {
+ kind: proposal.record.record_type === "decision_record" ? "decision" : "experiment",
+ record: proposal.record,
+ version: 1,
+ },
+ ] : []);
+
+ const savedRecords = await writeRecords({
+ projectRoot,
+ records: items.map((item) => ({
+ kind: item.kind,
+ id: item.record.id,
+ version: item.version,
+ value: item.record,
+ })),
+ });
+ written.push(...items.map((item, index) => ({
+ ...item,
+ ...savedRecords[index],
+ })));
+
+ try {
+ return {
+ items,
+ written,
+ ...await projectRecords({ projectRoot, registry, written }),
+ };
+ } catch (cause) {
+ return {
+ items,
+ written,
+ projection_stale: true,
+ warning: projectionIssue(cause),
+ };
+ }
+}
+
+export function createGenesisService({
+ projectRoot,
+ repoRoot,
+ clock = DEFAULT_CLOCK,
+ confirm = DEFAULT_CONFIRM,
+ registry,
+ projectRecords = projectWrittenRecords,
+} = {}) {
+ if (!projectRoot) {
+ throw new GenesisError("PROJECT_ROOT_REQUIRED", "A project root is required", {
+ path: "/projectRoot",
+ correction: "Pass a workspace root directory when creating the service",
+ escalation: "builder",
+ });
+ }
+
+ const activeRegistry = registry ?? createSchemaRegistry(repoRoot ?? path.resolve(import.meta.dirname, "../.."));
+
+ async function runWithProposal(buildProposal, commandName) {
+ return withWorkspaceLock(projectRoot, async () => {
+ const proposal = await buildProposal();
+ if (!(await confirm(proposal))) {
+ return proposalCancelled();
+ }
+
+ const persisted = await persistCommand({
+ projectRoot,
+ registry: activeRegistry,
+ proposal,
+ projectRecords,
+ });
+
+ const status = currentStatus({
+ projectRoot,
+ businessId: proposal.business_id,
+ now: clock().toISOString(),
+ });
+
+ const primaryRecord = proposal.record ?? proposal.records.at(-1)?.record ?? null;
+ const primaryPath = persisted.written.at(-1)?.relativePath ?? null;
+
+ return {
+ changed: true,
+ command: commandName,
+ business_id: proposal.business_id,
+ state: status.state,
+ next_command: status.next_command,
+ status,
+ record: primaryRecord,
+ records: persisted.items.map((item, index) => ({
+ ...item.record,
+ path: persisted.written[index]?.relativePath ?? null,
+ })),
+ path: primaryPath,
+ paths: persisted.written.map((item) => item.relativePath),
+ projection_stale: persisted.projection_stale,
+ warning: persisted.warning ?? null,
+ };
+ });
+ }
+
+ return {
+ async startBusiness(input) {
+ return runWithProposal(() => {
+ const normalized = normalizeBusinessId(input.business_id);
+ const { decisionEntries } = businessEntries(projectRoot, normalized);
+ if (decisionEntries.length > 0) {
+ throw new GenesisError("BUSINESS_ALREADY_EXISTS", "Business opportunity already exists", {
+ path: "/business_id",
+ correction: "Use add-evidence or plan-experiment for the existing opportunity",
+ escalation: "builder",
+ });
+ }
+ return startBusinessProposal({ ...input, business_id: normalized }, clock, activeRegistry);
+ }, "start-business");
+ },
+
+ async addEvidence(businessId, input) {
+ return runWithProposal(() => addEvidenceProposal(projectRoot, businessId, input, clock, activeRegistry), "add-evidence");
+ },
+
+ async planExperiment(businessId, input) {
+ return runWithProposal(() => planExperimentProposal(projectRoot, businessId, input, clock, activeRegistry), "plan-experiment");
+ },
+
+ async status(businessId) {
+ return withWorkspaceLock(projectRoot, async () => currentStatus({
+ projectRoot,
+ businessId,
+ now: clock().toISOString(),
+ }));
+ },
+
+ async rebuildIndex() {
+ return withWorkspaceLock(projectRoot, async () => {
+ const rebuilt = await rebuildProjection({
+ projectRoot,
+ registry: activeRegistry,
+ });
+ return {
+ ...rebuilt,
+ projection_consistent: true,
+ };
+ });
+ },
+ };
+}
diff --git a/src/cli/prompter.mjs b/src/cli/prompter.mjs
new file mode 100644
index 0000000..12203d5
--- /dev/null
+++ b/src/cli/prompter.mjs
@@ -0,0 +1,61 @@
+import readline from "node:readline/promises";
+
+function normalizeChoices(choices = []) {
+ return choices.map((choice) => (
+ typeof choice === "string"
+ ? { label: choice, value: choice }
+ : choice
+ ));
+}
+
+export function createPrompter({ input, output }) {
+ const interface_ = readline.createInterface({
+ input,
+ output,
+ });
+
+ async function ask(question) {
+ return interface_.question(question);
+ }
+
+ async function choose(question, choices) {
+ const normalized = normalizeChoices(choices);
+ const lines = normalized.map((choice, index) => ` ${index + 1}. ${choice.label}`).join("\n");
+ while (true) {
+ const answer = (await ask(`${question}\n${lines}\n> `)).trim();
+ if (!answer) {
+ return normalized[0]?.value;
+ }
+
+ const numeric = Number(answer);
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= normalized.length) {
+ return normalized[numeric - 1].value;
+ }
+
+ const direct = normalized.find((choice) => (
+ choice.value === answer || choice.label === answer
+ ));
+ if (direct) {
+ return direct.value;
+ }
+
+ output.write("Invalid choice. Enter a listed number, label, or value.\n");
+ }
+ }
+
+ async function confirm(question) {
+ const answer = (await ask(question)).trim().toLowerCase();
+ return ["y", "yes", "true", "1"].includes(answer);
+ }
+
+ async function close() {
+ await interface_.close();
+ }
+
+ return {
+ ask,
+ choose,
+ confirm,
+ close,
+ };
+}
diff --git a/src/cli/render.mjs b/src/cli/render.mjs
new file mode 100644
index 0000000..ad1c448
--- /dev/null
+++ b/src/cli/render.mjs
@@ -0,0 +1,73 @@
+import YAML from "yaml";
+
+import { formatError } from "../core/errors.mjs";
+
+function renderList(values) {
+ if (!Array.isArray(values) || values.length === 0) {
+ return "none";
+ }
+
+ return values.map((value) => `- ${value}`).join("\n");
+}
+
+function renderKeyValueLines(entries) {
+ return entries
+ .filter(([, value]) => value !== undefined && value !== null && value !== "")
+ .map(([label, value]) => `${label}: ${value}`)
+ .join("\n");
+}
+
+export function renderProposal(proposal) {
+ return [
+ "Proposed record:",
+ YAML.stringify(proposal).trimEnd(),
+ "",
+ ].join("\n");
+}
+
+export function renderStatus(status) {
+ const blocked = status.blocked_commands_by_code ?? {};
+ const blockedLines = Object.keys(blocked).length > 0
+ ? Object.entries(blocked).map(([code, count]) => `${code}: ${count}`).join("\n")
+ : "none";
+ const limits = status.limits
+ ? renderKeyValueLines([
+ ["cash_usd", status.limits.cash_usd],
+ ["labor_hours", status.limits.labor_hours],
+ ["duration_days", status.limits.duration_days],
+ ["data_classes", Array.isArray(status.limits.data_classes) ? status.limits.data_classes.join(", ") : status.limits.data_classes],
+ ["risk_level", status.limits.risk_level],
+ ])
+ : "none";
+
+ return [
+ `Business ID: ${status.business_id}`,
+ `State: ${status.state}`,
+ `Next command: ${status.next_command ?? status.next_permitted_command ?? "status"}`,
+ `Decision versions: ${status.decision_versions}`,
+ `Experiment versions: ${status.experiment_versions}`,
+ `Evidence count: ${status.evidence_count}`,
+ `Supporting evidence: ${status.metrics?.supporting_evidence_count ?? 0}`,
+ `Contradicting evidence: ${status.metrics?.contradicting_evidence_count ?? 0}`,
+ `Discover gate: ${status.discover_gate?.passed ? "passed" : "blocked"}`,
+ `Missing preregistration fields: ${renderList(status.experiment_completeness?.missing)}`,
+ "Limits:",
+ limits,
+ `Blocked commands: ${blockedLines}`,
+ `Projection consistent: ${status.projection_consistent ? "yes" : "no"}`,
+ `Preregistration completeness: ${status.metrics?.preregistration_completeness ?? status.experiment_completeness?.ratio ?? 0}`,
+ `Confidence history: ${(status.metrics?.confidence_history ?? []).join(", ") || "none"}`,
+ ].join("\n");
+}
+
+export function renderRebuildResult(result) {
+ return [
+ `Records rebuilt: ${result.recordCount}`,
+ `Businesses rebuilt: ${result.businessCount}`,
+ `Projection consistent: ${result.projection_consistent ? "yes" : "no"}`,
+ ].join("\n");
+}
+
+export function renderCliError(error) {
+ return formatError(error);
+}
diff --git a/src/cli/run-cli.mjs b/src/cli/run-cli.mjs
new file mode 100644
index 0000000..f8d0704
--- /dev/null
+++ b/src/cli/run-cli.mjs
@@ -0,0 +1,287 @@
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+
+import { createGenesisService } from "../application/genesis-service.mjs";
+import { suggestionsFor } from "../core/suggestions.mjs";
+import { GenesisError, formatError } from "../core/errors.mjs";
+import { createPrompter } from "./prompter.mjs";
+import { renderCliError, renderProposal, renderRebuildResult, renderStatus } from "./render.mjs";
+
+const HELP = [
+ "Usage:",
+ " genesis start-business",
+ " genesis add-evidence ",
+ " genesis status ",
+ " genesis plan-experiment ",
+ " genesis rebuild-index",
+].join("\n");
+
+function writeLine(stream, text = "") {
+ stream.write(`${text}\n`);
+}
+
+function showSuggestions(output) {
+ const suggestions = suggestionsFor("validation_methods");
+ if (suggestions.length === 0) {
+ return;
+ }
+
+ writeLine(output, "Offline suggestion — not evidence:");
+ for (const suggestion of suggestions) {
+ writeLine(output, `- ${suggestion}`);
+ }
+}
+
+function parseCommaList(value, fallback = []) {
+ const text = typeof value === "string" ? value.trim() : "";
+ if (!text) {
+ return fallback;
+ }
+ return text.split(",").map((part) => part.trim()).filter(Boolean);
+}
+
+function parseNumber(value, fallback = 0) {
+ const text = typeof value === "string" ? value.trim() : "";
+ if (!text) {
+ return fallback;
+ }
+ const parsed = Number(text);
+ if (!Number.isFinite(parsed)) {
+ throw new GenesisError("INPUT_INVALID", "Numeric input is invalid", {
+ path: "/input",
+ correction: "Enter a finite number",
+ escalation: "operator",
+ });
+ }
+ return parsed;
+}
+
+async function gatherStartBusinessInput(prompter, output) {
+ showSuggestions(output);
+ const business_id = await prompter.ask("Business ID: ");
+ const target_customer = await prompter.ask("Target customer: ");
+ const problem = await prompter.ask("Problem: ");
+ const hypothesis = await prompter.ask("Hypothesis: ");
+ const confidence = parseNumber(await prompter.ask("Confidence (0-1): "), 0.5);
+ const source_reference = await prompter.ask("Initial evidence source reference: ");
+ const summary = await prompter.ask("Initial evidence summary: ");
+ const stance = await prompter.choose("Initial evidence stance:", ["support", "contradict"]);
+ const provenance = await prompter.ask("Evidence provenance: ");
+ const privacy_classification = await prompter.choose("Privacy classification:", ["internal", "public", "confidential"]);
+ const counterevidence = parseCommaList(await prompter.ask("Counterevidence (comma-separated): "));
+ const alternatives = parseCommaList(await prompter.ask("Alternatives (comma-separated): "));
+ const expected_outcome = await prompter.ask("Expected outcome: ");
+ const metric = await prompter.ask("Metric: ");
+ const decision = await prompter.ask("Decision: ");
+ const owner = await prompter.ask("Owner: ");
+ const review_date = await prompter.ask("Review date: ");
+
+ return {
+ business_id,
+ target_customer,
+ problem,
+ hypothesis,
+ confidence,
+ source_reference,
+ summary,
+ stance,
+ provenance,
+ privacy_classification,
+ counterevidence,
+ alternatives,
+ expected_outcome,
+ metric,
+ decision,
+ owner,
+ review_date,
+ };
+}
+
+async function gatherAddEvidenceInput(prompter, output) {
+ showSuggestions(output);
+ const source_reference = await prompter.ask("Source reference: ");
+ const summary = await prompter.ask("Summary: ");
+ const stance = await prompter.choose("Evidence stance:", ["support", "contradict"]);
+ const provenance = await prompter.ask("Provenance: ");
+ const privacy_classification = await prompter.choose("Privacy classification:", ["internal", "public", "confidential"]);
+ return {
+ source_reference,
+ summary,
+ stance,
+ provenance,
+ privacy_classification,
+ };
+}
+
+async function gatherPlanExperimentInput(prompter, output, currentDecisionId) {
+ showSuggestions(output);
+ const supported_decision = await prompter.ask(`Supported decision [${currentDecisionId}]: `) || currentDecisionId;
+ const owner = await prompter.ask("Owner: ");
+ const baseline = await prompter.ask("Baseline: ");
+ const comparison_method = await prompter.ask("Comparison method: ");
+ const formula = await prompter.ask("Formula: ");
+ const population = await prompter.ask("Population: ");
+ const denominator = await prompter.ask("Denominator: ");
+ const data_source = await prompter.ask("Data source: ");
+ const expected_outcome = await prompter.ask("Expected outcome: ");
+ const minimum_meaningful_effect = await prompter.ask("Minimum meaningful effect: ");
+ const failure_conditions = parseCommaList(await prompter.ask("Failure conditions (comma-separated): "));
+ const stop_conditions = parseCommaList(await prompter.ask("Stop conditions (comma-separated): "));
+ const cash_usd = parseNumber(await prompter.ask("Maximum cash: "), 0);
+ const labor_hours = parseNumber(await prompter.ask("Maximum labor hours: "), 0);
+ const duration_days = parseNumber(await prompter.ask("Maximum duration days: "), 1);
+ const data_classes = parseCommaList(await prompter.ask("Data classes (comma-separated): "), ["internal"]);
+ const risk_level = await prompter.choose("Risk level:", ["low", "medium", "high", "critical"]);
+ const decision_date = await prompter.ask("Decision date: ");
+ const allowed_outcomes = parseCommaList(await prompter.ask("Allowed outcomes (comma-separated): "), ["scale", "pivot", "learning_lab", "archive", "kill"]);
+
+ return {
+ supported_decision,
+ owner,
+ baseline,
+ comparison_method,
+ metric: {
+ formula,
+ population,
+ denominator,
+ data_source,
+ },
+ expected_outcome,
+ minimum_meaningful_effect,
+ failure_conditions,
+ stop_conditions,
+ limits: {
+ cash_usd,
+ labor_hours,
+ duration_days,
+ data_classes,
+ risk_level,
+ },
+ decision_date,
+ allowed_outcomes,
+ };
+}
+
+function usage(output) {
+ writeLine(output, HELP);
+}
+
+function isGenesisError(error) {
+ return error instanceof GenesisError || typeof error?.code === "string";
+}
+
+export async function runCli(argv, dependencies = {}) {
+ const args = [...argv];
+ if (args[0] === "genesis") {
+ args.shift();
+ }
+
+ const output = dependencies.output ?? process.stdout;
+ const errorOutput = dependencies.errorOutput ?? process.stderr;
+ const input = dependencies.input ?? process.stdin;
+ const prompter = dependencies.prompter ?? createPrompter({ input, output });
+ const projectRoot = dependencies.projectRoot ?? process.cwd();
+ const repoRoot = dependencies.repoRoot ?? path.resolve(fileURLToPath(new URL("../..", import.meta.url)));
+ const clock = dependencies.clock ?? (() => new Date());
+ const confirm = dependencies.confirm ?? (async (proposal) => {
+ writeLine(output, renderProposal(proposal));
+ return prompter.confirm("Save this immutable record? [y/N] ");
+ });
+
+ const service = dependencies.service ?? createGenesisService({
+ projectRoot,
+ repoRoot,
+ clock,
+ confirm,
+ });
+
+ try {
+ const [command, businessId] = args;
+ if (!command || command === "--help" || command === "-h" || command === "help") {
+ usage(output);
+ return 0;
+ }
+
+ if (command === "start-business") {
+ const inputData = await gatherStartBusinessInput(prompter, output);
+ const result = await service.startBusiness(inputData);
+ if (!result.changed) {
+ writeLine(output, "Cancelled.");
+ return 0;
+ }
+ if (result.warning) {
+ writeLine(errorOutput, renderCliError(result.warning));
+ }
+ writeLine(output, renderStatus(result.status));
+ return 0;
+ }
+
+ if (command === "add-evidence") {
+ if (!businessId) {
+ usage(output);
+ return 2;
+ }
+ const inputData = await gatherAddEvidenceInput(prompter, output);
+ const result = await service.addEvidence(businessId, inputData);
+ if (!result.changed) {
+ writeLine(output, "Cancelled.");
+ return 0;
+ }
+ if (result.warning) {
+ writeLine(errorOutput, renderCliError(result.warning));
+ }
+ writeLine(output, renderStatus(result.status));
+ return 0;
+ }
+
+ if (command === "status") {
+ if (!businessId) {
+ usage(output);
+ return 2;
+ }
+ const status = await service.status(businessId);
+ writeLine(output, renderStatus(status));
+ return 0;
+ }
+
+ if (command === "plan-experiment") {
+ if (!businessId) {
+ usage(output);
+ return 2;
+ }
+ const status = await service.status(businessId);
+ const currentDecisionId = status.latest_decision_path
+ ? status.latest_decision_path.split("/").at(-1)?.replace(/\.v\d{4}\.yaml$/, "")
+ : `${businessId}-decision`;
+ const inputData = await gatherPlanExperimentInput(prompter, output, currentDecisionId);
+ const result = await service.planExperiment(businessId, inputData);
+ if (!result.changed) {
+ writeLine(output, "Cancelled.");
+ return 0;
+ }
+ if (result.warning) {
+ writeLine(errorOutput, renderCliError(result.warning));
+ }
+ writeLine(output, renderStatus(result.status));
+ return 0;
+ }
+
+ if (command === "rebuild-index") {
+ const result = await service.rebuildIndex();
+ writeLine(output, renderRebuildResult(result));
+ return 0;
+ }
+
+ usage(output);
+ return 2;
+ } catch (error) {
+ if (isGenesisError(error)) {
+ writeLine(errorOutput, renderCliError(error));
+ return 1;
+ }
+ writeLine(errorOutput, error?.stack ?? formatError(error));
+ return 1;
+ } finally {
+ await prompter.close?.();
+ }
+}
diff --git a/src/core/discovery-workflow.mjs b/src/core/discovery-workflow.mjs
new file mode 100644
index 0000000..f7c4422
--- /dev/null
+++ b/src/core/discovery-workflow.mjs
@@ -0,0 +1,184 @@
+import { fileURLToPath } from "node:url";
+import fs from "node:fs";
+import path from "node:path";
+
+import YAML from "yaml";
+
+import { calculateMetrics, PREREGISTRATION_REQUIRED_FIELDS } from "./metrics.mjs";
+
+const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
+const WORKFLOW_PATH = path.join(REPO_ROOT, "config", "workflows", "experiment-lifecycle.yaml");
+const WORKFLOW = YAML.parse(fs.readFileSync(WORKFLOW_PATH, "utf8"));
+
+function toDate(value) {
+ if (value instanceof Date) {
+ return value;
+ }
+
+ return new Date(value);
+}
+
+function isMissingValue(value) {
+ if (value === null || value === undefined) {
+ return true;
+ }
+
+ if (typeof value === "string") {
+ return value.trim().length === 0;
+ }
+
+ if (Array.isArray(value)) {
+ return value.length === 0;
+ }
+
+ return false;
+}
+
+function getPathValue(object, pathParts) {
+ let current = object;
+
+ for (const part of pathParts) {
+ if (current === null || current === undefined) {
+ return undefined;
+ }
+
+ current = current[part];
+ }
+
+ return current;
+}
+
+function requiredPaths() {
+ return PREREGISTRATION_REQUIRED_FIELDS.map((field) => `/${field.replaceAll(".", "/")}`);
+}
+
+function orderedVersions(values = []) {
+ return [...values].sort((left, right) => {
+ const leftVersion = Number.isFinite(left?.version) ? left.version : 0;
+ const rightVersion = Number.isFinite(right?.version) ? right.version : 0;
+ if (leftVersion !== rightVersion) {
+ return leftVersion - rightVersion;
+ }
+
+ const leftTime = toDate(left?.created_at ?? left?.updated_at ?? 0).getTime();
+ const rightTime = toDate(right?.created_at ?? right?.updated_at ?? 0).getTime();
+ return leftTime - rightTime;
+ });
+}
+
+function latestVersion(values = []) {
+ return orderedVersions(values).at(-1) ?? null;
+}
+
+function blocker(path, correction) {
+ return {
+ code: "DISCOVER_GATE_BLOCKED",
+ path,
+ correction,
+ escalation: "builder",
+ };
+}
+
+export function evaluateDiscoverGate({ decision, evidence }) {
+ const blockers = [];
+
+ if (isMissingValue(decision?.target_customer)) {
+ blockers.push(blocker("/target_customer", "Provide target customer"));
+ }
+
+ if (isMissingValue(decision?.problem)) {
+ blockers.push(blocker("/problem", "Provide problem"));
+ }
+
+ if (isMissingValue(decision?.hypothesis)) {
+ blockers.push(blocker("/hypothesis", "Provide hypothesis"));
+ }
+
+ if (!Array.isArray(evidence) || evidence.length === 0) {
+ blockers.push(blocker("/evidence", "Add at least one confirmed evidence entry"));
+ }
+
+ return {
+ passed: blockers.length === 0,
+ blockers,
+ };
+}
+
+export function experimentCompleteness(experiment) {
+ const required = requiredPaths();
+ const present = [];
+ const missing = [];
+
+ for (const field of PREREGISTRATION_REQUIRED_FIELDS) {
+ const pathName = `/${field.replaceAll(".", "/")}`;
+ const value = getPathValue(experiment, field.split("."));
+ if (field === "counterevidence" ? value === null || value === undefined : isMissingValue(value)) {
+ missing.push(pathName);
+ } else {
+ present.push(pathName);
+ }
+ }
+
+ return {
+ complete: missing.length === 0,
+ present,
+ required,
+ missing,
+ ratio: required.length === 0 ? 0 : present.length / required.length,
+ };
+}
+
+function countByCode(blockedCommands = []) {
+ const counts = {};
+
+ for (const command of blockedCommands) {
+ const code = command?.code;
+ if (!code) {
+ continue;
+ }
+
+ counts[code] = (counts[code] ?? 0) + 1;
+ }
+
+ return counts;
+}
+
+export function buildStatus({ decisionVersions = [], experimentVersions = [], evidence = [], blockedCommands = [], consistency, now } = {}) {
+ const decision = latestVersion(decisionVersions);
+ const experiment = latestVersion(experimentVersions);
+ const discoverGate = evaluateDiscoverGate({ decision, evidence });
+ const experimentCompletenessResult = experimentCompleteness(experiment ?? {});
+ const metrics = calculateMetrics({
+ decisionVersions,
+ experimentVersions,
+ evidence,
+ blockedCommands,
+ consistency,
+ now,
+ });
+
+ const hasExperiment = experiment !== null;
+ const experimentIsComplete = experimentCompletenessResult.complete;
+ const state = !hasExperiment
+ ? "discover"
+ : experiment.status === "draft"
+ ? (experimentIsComplete ? "approval_pending" : "discover")
+ : experiment.status;
+ const nextCommand = hasExperiment
+ ? "status"
+ : (discoverGate.passed ? "plan-experiment" : "status");
+
+ return {
+ state,
+ next_command: nextCommand,
+ next_permitted_command: nextCommand,
+ decision_versions: decisionVersions.length,
+ experiment_versions: experimentVersions.length,
+ evidence_count: evidence.length,
+ discover_gate: discoverGate,
+ experiment_completeness: experimentCompletenessResult,
+ blocked_commands_by_code: countByCode(blockedCommands),
+ projection_consistent: Boolean(consistency?.consistent ?? consistency),
+ metrics,
+ };
+}
diff --git a/src/core/errors.mjs b/src/core/errors.mjs
new file mode 100644
index 0000000..2259801
--- /dev/null
+++ b/src/core/errors.mjs
@@ -0,0 +1,25 @@
+export class GenesisError extends Error {
+ constructor(code, message, {
+ path,
+ correction,
+ escalation,
+ cause,
+ } = {}) {
+ super(message, cause === undefined ? undefined : { cause });
+ this.name = "GenesisError";
+ this.code = code;
+ this.path = path;
+ this.correction = correction;
+ this.escalation = escalation;
+ }
+}
+
+export function formatError(error) {
+ const lines = [`${error.code ?? "UNEXPECTED_ERROR"}: ${error.message ?? String(error)}`];
+
+ if (error.path) lines.push(`Path: ${error.path}`);
+ if (error.correction) lines.push(`Correction: ${error.correction}`);
+ if (error.escalation) lines.push(`Escalation: ${error.escalation}`);
+
+ return lines.join("\n");
+}
diff --git a/src/core/ids.mjs b/src/core/ids.mjs
new file mode 100644
index 0000000..acdc48f
--- /dev/null
+++ b/src/core/ids.mjs
@@ -0,0 +1,31 @@
+import { GenesisError } from "./errors.mjs";
+
+const CANONICAL_ID = /^[a-z0-9][a-z0-9-]*$/;
+
+export function normalizeBusinessId(value) {
+ const normalized = typeof value === "string"
+ ? value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "")
+ : "";
+
+ if (!CANONICAL_ID.test(normalized)) {
+ throw new GenesisError("BUSINESS_ID_INVALID", "Business ID is invalid", {
+ path: "/business_id",
+ correction: "Enter a business ID containing at least one letter or number",
+ escalation: "operator",
+ });
+ }
+
+ return normalized;
+}
+
+export function versionFileName(id, version) {
+ if (!Number.isInteger(version) || version <= 0) {
+ throw new GenesisError("RECORD_VERSION_INVALID", "Record version is invalid", {
+ path: "/version",
+ correction: "Use a positive integer record version",
+ escalation: "operator",
+ });
+ }
+
+ return `${id}.v${String(version).padStart(4, "0")}.yaml`;
+}
diff --git a/src/core/metrics.mjs b/src/core/metrics.mjs
new file mode 100644
index 0000000..860a8d2
--- /dev/null
+++ b/src/core/metrics.mjs
@@ -0,0 +1,150 @@
+import { fileURLToPath } from "node:url";
+import fs from "node:fs";
+import path from "node:path";
+
+import YAML from "yaml";
+
+const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
+const WORKFLOW_PATH = path.join(REPO_ROOT, "config", "workflows", "experiment-lifecycle.yaml");
+const WORKFLOW = YAML.parse(fs.readFileSync(WORKFLOW_PATH, "utf8"));
+if (
+ !Array.isArray(WORKFLOW?.preregistration_required_fields)
+ || WORKFLOW.preregistration_required_fields.length === 0
+ || WORKFLOW.preregistration_required_fields.some((field) => typeof field !== "string" || field.trim().length === 0)
+) {
+ throw new Error("Workflow preregistration_required_fields must be a non-empty string array");
+}
+const PREREGISTRATION_REQUIRED_FIELDS = Object.freeze([
+ ...WORKFLOW.preregistration_required_fields,
+]);
+
+function toDate(value) {
+ if (value instanceof Date) {
+ return value;
+ }
+
+ return new Date(value);
+}
+
+function isFiniteNumber(value) {
+ return typeof value === "number" && Number.isFinite(value);
+}
+
+function isMissingValue(value) {
+ if (value === null || value === undefined) {
+ return true;
+ }
+
+ if (typeof value === "string") {
+ return value.trim().length === 0;
+ }
+
+ if (Array.isArray(value)) {
+ return value.length === 0;
+ }
+
+ return false;
+}
+
+function getPathValue(object, pathParts) {
+ let current = object;
+
+ for (const part of pathParts) {
+ if (current === null || current === undefined) {
+ return undefined;
+ }
+
+ current = current[part];
+ }
+
+ return current;
+}
+
+function orderedVersions(values = []) {
+ return [...values].sort((left, right) => {
+ const leftVersion = Number.isFinite(left?.version) ? left.version : 0;
+ const rightVersion = Number.isFinite(right?.version) ? right.version : 0;
+ if (leftVersion !== rightVersion) {
+ return leftVersion - rightVersion;
+ }
+
+ const leftTime = toDate(left?.created_at ?? left?.updated_at ?? 0).getTime();
+ const rightTime = toDate(right?.created_at ?? right?.updated_at ?? 0).getTime();
+ return leftTime - rightTime;
+ });
+}
+
+function firstRecordTime(values, keys) {
+ const ordered = orderedVersions(values);
+ const selected = ordered[0];
+ if (!selected) {
+ return null;
+ }
+
+ for (const key of keys) {
+ if (selected[key]) {
+ return toDate(selected[key]).getTime();
+ }
+ }
+
+ return null;
+}
+
+function countByCode(blockedCommands = []) {
+ const counts = {};
+
+ for (const command of blockedCommands) {
+ const code = command?.code;
+ if (!code) {
+ continue;
+ }
+
+ counts[code] = (counts[code] ?? 0) + 1;
+ }
+
+ return counts;
+}
+
+function completenessForExperiment(experiment) {
+ let present = 0;
+
+ for (const field of PREREGISTRATION_REQUIRED_FIELDS) {
+ const value = getPathValue(experiment, field.split("."));
+ if (field === "counterevidence" ? value !== null && value !== undefined : !isMissingValue(value)) {
+ present += 1;
+ }
+ }
+
+ return PREREGISTRATION_REQUIRED_FIELDS.length === 0
+ ? 0
+ : present / PREREGISTRATION_REQUIRED_FIELDS.length;
+}
+
+export function calculateMetrics(input = {}) {
+ const decisionVersions = orderedVersions(input.decisionVersions);
+ const experimentVersions = orderedVersions(input.experimentVersions);
+ const now = toDate(input.now ?? new Date());
+ const latestExperiment = experimentVersions.at(-1);
+ const latestDecisionCreatedAt = firstRecordTime(decisionVersions, ["created_at", "updated_at"]);
+ const latestExperimentPlanAt = firstRecordTime(experimentVersions, ["created_at", "updated_at"]);
+
+ const supportingEvidenceCount = (input.evidence ?? []).filter((entry) => entry?.stance === "support").length;
+ const contradictingEvidenceCount = (input.evidence ?? []).filter((entry) => entry?.stance === "contradict").length;
+
+ return Object.freeze({
+ supporting_evidence_count: supportingEvidenceCount,
+ contradicting_evidence_count: contradictingEvidenceCount,
+ discover_days: latestDecisionCreatedAt === null ? 0 : (now.getTime() - latestDecisionCreatedAt) / 86_400_000,
+ time_to_validation_plan_days: latestDecisionCreatedAt === null || latestExperimentPlanAt === null
+ ? 0
+ : (latestExperimentPlanAt - latestDecisionCreatedAt) / 86_400_000,
+ preregistration_completeness: latestExperiment ? completenessForExperiment(latestExperiment) : 0,
+ confidence_history: decisionVersions
+ .map((version) => version?.confidence)
+ .filter(isFiniteNumber),
+ blocked_commands_by_code: countByCode(input.blockedCommands),
+ projection_consistent: Boolean(input.consistency?.consistent ?? input.consistency),
+ });
+}
+
+export { PREREGISTRATION_REQUIRED_FIELDS };
diff --git a/src/core/record-builders.mjs b/src/core/record-builders.mjs
new file mode 100644
index 0000000..c3e690d
--- /dev/null
+++ b/src/core/record-builders.mjs
@@ -0,0 +1,169 @@
+import { fileURLToPath } from "node:url";
+
+import { GenesisError } from "./errors.mjs";
+import { normalizeBusinessId } from "./ids.mjs";
+import { createSchemaRegistry } from "./schema-registry.mjs";
+
+const POLICY_VERSION = "2.0.0";
+const SCHEMA_VERSION = "1.0.0";
+const REPO_ROOT = fileURLToPath(new URL("../..", import.meta.url));
+const registry = createSchemaRegistry(REPO_ROOT);
+
+function timestamp(clock) {
+ return clock().toISOString();
+}
+
+function privacy(input) {
+ return input.privacy_classification ?? "internal";
+}
+
+function resolveRegistry(options = {}) {
+ if (options.registry) {
+ return options.registry;
+ }
+
+ if (options.repoRoot) {
+ return createSchemaRegistry(options.repoRoot);
+ }
+
+ return registry;
+}
+
+function rejectRestrictedExperimentData(limits) {
+ if (limits?.data_classes?.includes("restricted")) {
+ throw new GenesisError("SENSITIVE_DATA_FORBIDDEN", "Restricted experiment data is forbidden", {
+ path: "/limits/data_classes",
+ correction: "Remove restricted from experiment data_classes and keep the workflow within public or internal data",
+ escalation: "human_authority",
+ });
+ }
+}
+
+export function buildEvidenceEntry(input, clock, options = {}) {
+ const activeRegistry = resolveRegistry(options);
+ if (privacy(input) === "restricted") {
+ throw new GenesisError("SENSITIVE_DATA_FORBIDDEN", "Restricted evidence is forbidden", {
+ path: "/privacy_classification",
+ correction: "Use a non-sensitive evidence reference and summary",
+ escalation: "human_authority",
+ });
+ }
+
+ const evidence = {
+ id: input.id,
+ business_id: normalizeBusinessId(input.business_id),
+ collected_at: timestamp(clock),
+ source_reference: input.source_reference,
+ summary: input.summary,
+ stance: input.stance,
+ provenance: input.provenance,
+ privacy_classification: privacy(input),
+ };
+
+ return activeRegistry.validateEvidence(evidence);
+}
+
+export function buildDecisionRecord(input, clock, options = {}) {
+ const activeRegistry = resolveRegistry(options);
+ const businessId = normalizeBusinessId(input.business_id);
+ const id = `${businessId}-decision`;
+ const now = timestamp(clock);
+ const decision = {
+ id,
+ record_type: "decision_record",
+ schema_version: SCHEMA_VERSION,
+ policy_version: POLICY_VERSION,
+ created_at: now,
+ updated_at: now,
+ owner: input.owner,
+ affected_business: businessId,
+ status: "draft",
+ evidence_references: input.evidence_references,
+ related_records: input.related_records ?? [],
+ privacy_classification: privacy(input),
+ immutable_history_refs: input.immutable_history_refs
+ ?? [`records/decisions/${id}.v0001.yaml`],
+ target_customer: input.target_customer,
+ problem: input.problem,
+ hypothesis: input.hypothesis,
+ confidence: input.confidence,
+ evidence: input.evidence,
+ counterevidence: input.counterevidence,
+ alternatives: input.alternatives,
+ expected_outcome: input.expected_outcome,
+ metric: input.metric,
+ decision: input.decision,
+ decision_date: now,
+ review_date: input.review_date,
+ actual_outcome: null,
+ confidence_update: null,
+ };
+
+ return activeRegistry.validateRecord("decision_record", decision);
+}
+
+export function versionDecisionRecord(previous, changes, historyRef, clock, options = {}) {
+ const activeRegistry = resolveRegistry(options);
+ const decision = {
+ ...previous,
+ ...changes,
+ id: previous.id,
+ record_type: "decision_record",
+ schema_version: SCHEMA_VERSION,
+ policy_version: POLICY_VERSION,
+ created_at: previous.created_at,
+ updated_at: timestamp(clock),
+ affected_business: previous.affected_business,
+ immutable_history_refs: [...new Set([
+ ...previous.immutable_history_refs,
+ historyRef,
+ ])],
+ };
+
+ return activeRegistry.validateRecord("decision_record", decision);
+}
+
+export function buildExperimentRecord(input, clock, options = {}) {
+ const activeRegistry = resolveRegistry(options);
+ const businessId = normalizeBusinessId(input.business_id);
+ const id = `${businessId}-experiment`;
+ const now = timestamp(clock);
+ rejectRestrictedExperimentData(input.limits);
+ const experiment = {
+ id,
+ record_type: "experiment_record",
+ schema_version: SCHEMA_VERSION,
+ policy_version: POLICY_VERSION,
+ created_at: now,
+ updated_at: now,
+ owner: input.owner,
+ affected_business: businessId,
+ status: "draft",
+ subtype: "validation",
+ validation_outcome: "pending",
+ evidence_references: input.evidence_references,
+ related_records: input.related_records ?? [],
+ privacy_classification: privacy(input),
+ immutable_history_refs: input.immutable_history_refs
+ ?? [`records/experiments/${id}.v0001.yaml`],
+ problem: input.problem,
+ supported_decision: input.supported_decision,
+ hypothesis: input.hypothesis,
+ confidence: input.confidence,
+ evidence: input.evidence,
+ counterevidence: input.counterevidence,
+ baseline: input.baseline,
+ comparison_method: input.comparison_method,
+ metric: input.metric,
+ expected_outcome: input.expected_outcome,
+ minimum_meaningful_effect: input.minimum_meaningful_effect,
+ failure_conditions: input.failure_conditions,
+ stop_conditions: input.stop_conditions,
+ limits: input.limits,
+ decision_date: input.decision_date,
+ allowed_outcomes: ["scale", "pivot", "learning_lab", "archive", "kill"],
+ approval_references: [],
+ };
+
+ return activeRegistry.validateRecord("experiment_record", experiment);
+}
diff --git a/src/core/schema-registry.mjs b/src/core/schema-registry.mjs
new file mode 100644
index 0000000..0259c4e
--- /dev/null
+++ b/src/core/schema-registry.mjs
@@ -0,0 +1,85 @@
+import fs from "node:fs";
+import path from "node:path";
+
+import Ajv2020 from "ajv/dist/2020.js";
+import addFormats from "ajv-formats";
+import YAML from "yaml";
+
+import { GenesisError } from "./errors.mjs";
+
+function resolveInsideRepo(repoRoot, relativePath) {
+ const resolved = path.resolve(repoRoot, relativePath);
+ const rootReal = fs.realpathSync(repoRoot);
+ const resolvedReal = fs.realpathSync(resolved);
+ if (resolvedReal !== rootReal && !resolvedReal.startsWith(`${rootReal}${path.sep}`)) {
+ throw new GenesisError("RECORD_SCHEMA_INVALID", "Record failed its registered schema", {
+ path: "/record_templates",
+ correction: `schema path must remain inside the repository: ${relativePath}`,
+ escalation: "builder",
+ });
+ }
+ return resolved;
+}
+
+function readManifest(manifestPath) {
+ const document = YAML.parseDocument(fs.readFileSync(manifestPath, "utf8"), {
+ prettyErrors: true,
+ strict: true,
+ uniqueKeys: true,
+ });
+ if (document.errors.length > 0) {
+ throw document.errors[0];
+ }
+ return document.toJS({ mapAsMap: false });
+}
+
+function validationError(errors) {
+ const ajvErrors = errors ?? [];
+ const first = ajvErrors[0];
+ return new GenesisError("RECORD_SCHEMA_INVALID", "Record failed its registered schema", {
+ path: first?.instancePath ?? "",
+ correction: ajvErrors.map((error) => {
+ const extra = error.keyword === "additionalProperties" && error.params?.additionalProperty
+ ? ` (unexpected ${error.params.additionalProperty})`
+ : "";
+ return `${error.instancePath || "/"} ${error.keyword}${extra}: ${error.message ?? "invalid value"}`;
+ }).join("; "),
+ escalation: "builder",
+ });
+}
+
+export function createSchemaRegistry(repoRoot) {
+ const root = path.resolve(repoRoot);
+ const manifest = readManifest(path.join(root, "genesis.yaml"));
+ const ajv = new Ajv2020({ allErrors: true, strict: true });
+ addFormats(ajv);
+
+ const recordValidators = new Map();
+ for (const descriptor of manifest.record_templates ?? []) {
+ const schemaPath = resolveInsideRepo(root, descriptor.schema);
+ const schema = JSON.parse(fs.readFileSync(schemaPath, "utf8"));
+ recordValidators.set(descriptor.id, ajv.compile(schema));
+ }
+
+ const evidencePath = resolveInsideRepo(root, "schemas/runtime/evidence-entry.schema.json");
+ const evidenceValidator = ajv.compile(JSON.parse(fs.readFileSync(evidencePath, "utf8")));
+
+ function validate(validator, value) {
+ if (!validator || !validator(value)) {
+ throw validationError(validator?.errors ?? [{
+ instancePath: "/record_type",
+ message: "must identify a manifest-registered record type",
+ }]);
+ }
+ return value;
+ }
+
+ return Object.freeze({
+ validateRecord(recordType, value) {
+ return validate(recordValidators.get(recordType), value);
+ },
+ validateEvidence(value) {
+ return validate(evidenceValidator, value);
+ },
+ });
+}
diff --git a/src/core/suggestions.mjs b/src/core/suggestions.mjs
new file mode 100644
index 0000000..7673a73
--- /dev/null
+++ b/src/core/suggestions.mjs
@@ -0,0 +1,13 @@
+const SUGGESTIONS = Object.freeze({
+ validation_methods: Object.freeze([
+ "Customer interviews",
+ "Preorders or letters of intent",
+ "Concierge pilot",
+ "Landing-page demand test",
+ ]),
+});
+
+export function suggestionsFor(topic) {
+ const suggestions = Object.hasOwn(SUGGESTIONS, topic) ? SUGGESTIONS[topic] : [];
+ return Object.freeze([...suggestions]);
+}
diff --git a/src/storage/projection.mjs b/src/storage/projection.mjs
new file mode 100644
index 0000000..25d7c28
--- /dev/null
+++ b/src/storage/projection.mjs
@@ -0,0 +1,371 @@
+import fs from "node:fs";
+import path from "node:path";
+
+import Database from "better-sqlite3";
+
+import { GenesisError } from "../core/errors.mjs";
+import { listRecords, readRecord } from "./yaml-record-store.mjs";
+import { ensureWorkspace, workspacePaths } from "./workspace.mjs";
+
+function dbPathFor(projectRoot) {
+ return workspacePaths(projectRoot).db;
+}
+
+function tempDbPathFor(projectRoot) {
+ return `${dbPathFor(projectRoot)}.rebuild.tmp`;
+}
+
+function recordTypeForKind(kind) {
+ if (kind === "decision") return "decision_record";
+ if (kind === "experiment") return "experiment_record";
+ if (kind === "evidence") return "evidence_entry";
+
+ throw new GenesisError("RECORD_KIND_INVALID", "Record kind is not supported", {
+ path: "/kind",
+ correction: "Use decision, experiment, or evidence",
+ escalation: "builder",
+ });
+}
+
+function schemaSql() {
+ return `
+ PRAGMA foreign_keys = ON;
+ CREATE TABLE IF NOT EXISTS record_versions (
+ record_type TEXT NOT NULL,
+ record_id TEXT NOT NULL,
+ version INTEGER NOT NULL,
+ relative_path TEXT NOT NULL UNIQUE,
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (record_type, record_id, version)
+ );
+ CREATE TABLE IF NOT EXISTS opportunities (
+ business_id TEXT PRIMARY KEY,
+ decision_id TEXT NOT NULL,
+ state TEXT NOT NULL,
+ created_at TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ latest_decision_path TEXT NOT NULL,
+ latest_experiment_path TEXT,
+ support_count INTEGER NOT NULL,
+ contradict_count INTEGER NOT NULL,
+ confidence REAL NOT NULL,
+ discover_started_at TEXT NOT NULL,
+ validation_planned_at TEXT,
+ projection_consistent INTEGER NOT NULL DEFAULT 1
+ );
+ CREATE TABLE IF NOT EXISTS blocked_commands (
+ id INTEGER PRIMARY KEY,
+ business_id TEXT,
+ command TEXT NOT NULL,
+ code TEXT NOT NULL,
+ occurred_at TEXT NOT NULL
+ );
+ `;
+}
+
+function createOpportunityDefaults(businessId, decisionId, startedAt) {
+ return {
+ business_id: businessId,
+ decision_id: decisionId,
+ state: "discover",
+ created_at: startedAt,
+ updated_at: startedAt,
+ latest_decision_path: "",
+ latest_experiment_path: null,
+ support_count: 0,
+ contradict_count: 0,
+ confidence: 0,
+ discover_started_at: startedAt,
+ validation_planned_at: null,
+ };
+}
+
+function getOpportunity(db, businessId) {
+ return db.prepare("SELECT * FROM opportunities WHERE business_id = ?").get(businessId) ?? null;
+}
+
+function upsertOpportunity(db, values) {
+ const current = getOpportunity(db, values.business_id);
+ const next = current
+ ? {
+ business_id: values.business_id,
+ decision_id: values.decision_id ?? current.decision_id,
+ state: values.state ?? current.state,
+ created_at: values.created_at ?? current.created_at,
+ updated_at: values.updated_at ?? current.updated_at,
+ latest_decision_path: values.latest_decision_path ?? current.latest_decision_path,
+ latest_experiment_path: values.latest_experiment_path ?? current.latest_experiment_path,
+ support_count: values.support_count ?? current.support_count,
+ contradict_count: values.contradict_count ?? current.contradict_count,
+ confidence: values.confidence ?? current.confidence,
+ discover_started_at: values.discover_started_at ?? current.discover_started_at,
+ validation_planned_at: values.validation_planned_at ?? current.validation_planned_at,
+ }
+ : values;
+
+ db.prepare(`
+ INSERT INTO opportunities (
+ business_id, decision_id, state, created_at, updated_at, latest_decision_path,
+ latest_experiment_path, support_count, contradict_count, confidence,
+ discover_started_at, validation_planned_at, projection_consistent
+ )
+ VALUES (
+ @business_id, @decision_id, @state, @created_at, @updated_at, @latest_decision_path,
+ @latest_experiment_path, @support_count, @contradict_count, @confidence,
+ @discover_started_at, @validation_planned_at, 1
+ )
+ ON CONFLICT(business_id) DO UPDATE SET
+ decision_id = excluded.decision_id,
+ state = excluded.state,
+ created_at = excluded.created_at,
+ updated_at = excluded.updated_at,
+ latest_decision_path = excluded.latest_decision_path,
+ latest_experiment_path = excluded.latest_experiment_path,
+ support_count = excluded.support_count,
+ contradict_count = excluded.contradict_count,
+ confidence = excluded.confidence,
+ discover_started_at = excluded.discover_started_at,
+ validation_planned_at = excluded.validation_planned_at,
+ projection_consistent = 1
+ `).run(next);
+}
+
+function upsertRecordVersion(db, descriptor, record) {
+ const result = db.prepare(`
+ INSERT INTO record_versions (record_type, record_id, version, relative_path, updated_at)
+ VALUES (?, ?, ?, ?, ?)
+ ON CONFLICT(record_type, record_id, version) DO NOTHING
+ `).run(
+ descriptor.kind,
+ descriptor.id,
+ descriptor.version,
+ descriptor.relativePath,
+ record.updated_at ?? record.collected_at ?? record.created_at ?? new Date().toISOString(),
+ );
+ return result.changes === 1;
+}
+
+function recordKindForType(recordType) {
+ if (recordType === "decision_record") return "decision";
+ if (recordType === "experiment_record") return "experiment";
+ if (recordType === "evidence_entry") return "evidence";
+ throw new GenesisError("RECORD_SCHEMA_INVALID", "Record failed its registered schema", {
+ path: "/record_type",
+ correction: "Use a manifest-registered record type",
+ escalation: "builder",
+ });
+}
+
+function validateProjectionRecord(registry, descriptor, record) {
+ const recordType = recordTypeForKind(descriptor.kind);
+ if (recordType === "evidence_entry") {
+ registry.validateEvidence(record);
+ } else {
+ registry.validateRecord(recordType, record);
+ }
+}
+
+function projectDecision(db, descriptor, record) {
+ const current = getOpportunity(db, record.affected_business);
+ upsertOpportunity(db, current
+ ? {
+ business_id: record.affected_business,
+ decision_id: record.id,
+ state: current.state,
+ created_at: current.created_at,
+ updated_at: record.updated_at,
+ latest_decision_path: descriptor.relativePath,
+ latest_experiment_path: current.latest_experiment_path,
+ support_count: current.support_count,
+ contradict_count: current.contradict_count,
+ confidence: record.confidence,
+ discover_started_at: current.discover_started_at,
+ validation_planned_at: current.validation_planned_at,
+ }
+ : {
+ ...createOpportunityDefaults(record.affected_business, record.id, record.created_at),
+ latest_decision_path: descriptor.relativePath,
+ confidence: record.confidence,
+ });
+}
+
+function projectEvidence(db, record) {
+ const current = getOpportunity(db, record.business_id);
+ const startedAt = current?.discover_started_at ?? record.collected_at;
+ const currentCounts = current ?? createOpportunityDefaults(record.business_id, `${record.business_id}-decision`, startedAt);
+ upsertOpportunity(db, {
+ ...currentCounts,
+ state: currentCounts.state ?? "discover",
+ decision_id: currentCounts.decision_id ?? `${record.business_id}-decision`,
+ created_at: currentCounts.created_at ?? startedAt,
+ updated_at: record.collected_at,
+ latest_decision_path: currentCounts.latest_decision_path ?? "",
+ latest_experiment_path: currentCounts.latest_experiment_path ?? null,
+ support_count: currentCounts.support_count + (record.stance === "support" ? 1 : 0),
+ contradict_count: currentCounts.contradict_count + (record.stance === "contradict" ? 1 : 0),
+ confidence: currentCounts.confidence,
+ discover_started_at: currentCounts.discover_started_at ?? startedAt,
+ validation_planned_at: currentCounts.validation_planned_at ?? null,
+ });
+}
+
+function projectExperiment(db, descriptor, record) {
+ const current = getOpportunity(db, record.affected_business);
+ upsertOpportunity(db, current
+ ? {
+ business_id: record.affected_business,
+ decision_id: current.decision_id,
+ state: record.status === "draft" ? "approval_pending" : record.status,
+ created_at: current.created_at,
+ updated_at: record.updated_at,
+ latest_decision_path: current.latest_decision_path,
+ latest_experiment_path: descriptor.relativePath,
+ support_count: current.support_count,
+ contradict_count: current.contradict_count,
+ confidence: current.confidence,
+ discover_started_at: current.discover_started_at,
+ validation_planned_at: record.decision_date,
+ }
+ : {
+ ...createOpportunityDefaults(record.affected_business, `${record.affected_business}-decision`, record.created_at),
+ state: record.status === "draft" ? "approval_pending" : record.status,
+ latest_experiment_path: descriptor.relativePath,
+ validation_planned_at: record.decision_date,
+ });
+}
+
+export function openProjection(dbPath) {
+ fs.mkdirSync(path.dirname(dbPath), { recursive: true, mode: 0o700 });
+ fs.chmodSync(path.dirname(dbPath), 0o700);
+ const db = new Database(dbPath);
+ db.pragma("foreign_keys = ON");
+ db.exec(schemaSql());
+ return db;
+}
+
+export function projectRecord(db, descriptor, record) {
+ const transaction = db.transaction(() => {
+ const inserted = upsertRecordVersion(db, descriptor, record);
+
+ if (descriptor.kind === "decision") {
+ projectDecision(db, descriptor, record);
+ return;
+ }
+
+ if (descriptor.kind === "evidence") {
+ if (inserted) {
+ projectEvidence(db, record);
+ }
+ return;
+ }
+
+ if (descriptor.kind === "experiment") {
+ projectExperiment(db, descriptor, record);
+ return;
+ }
+
+ throw new GenesisError("RECORD_KIND_INVALID", "Record kind is not supported", {
+ path: "/kind",
+ correction: "Use decision, experiment, or evidence",
+ escalation: "builder",
+ });
+ });
+
+ return transaction();
+}
+
+export function recordBlockedCommand(db, event) {
+ db.prepare(`
+ INSERT INTO blocked_commands (business_id, command, code, occurred_at)
+ VALUES (?, ?, ?, ?)
+ `).run(
+ event.businessId ?? event.business_id ?? null,
+ event.command,
+ event.code,
+ event.occurredAt ?? event.occurred_at,
+ );
+}
+
+export function readOpportunity(db, businessId) {
+ return getOpportunity(db, businessId);
+}
+
+export function projectionConsistency(db, descriptors) {
+ const yamlCount = descriptors.length;
+ const projected = db.prepare(`
+ SELECT record_type, record_id, version, relative_path
+ FROM record_versions
+ `).all();
+ const projectedCount = projected.length;
+ const identity = ({ kind, id, version, relativePath }) => (
+ `${kind}\u0000${id}\u0000${version}\u0000${relativePath}`
+ );
+ const yamlIdentities = new Set(descriptors.map(identity));
+ const projectedIdentities = new Set(projected.map((row) => identity({
+ kind: row.record_type,
+ id: row.record_id,
+ version: row.version,
+ relativePath: row.relative_path,
+ })));
+ const exactMatch = yamlIdentities.size === projectedIdentities.size
+ && [...yamlIdentities].every((value) => projectedIdentities.has(value));
+ return {
+ consistent: exactMatch,
+ yamlCount,
+ projectedCount,
+ };
+}
+
+export function rebuildProjection({ projectRoot, registry }) {
+ if (!registry) {
+ throw new GenesisError("REGISTRY_REQUIRED", "A schema registry must be supplied for rebuilds", {
+ path: "/registry",
+ correction: "Pass createSchemaRegistry(repoRoot) from the canonical repository root",
+ escalation: "builder",
+ });
+ }
+
+ ensureWorkspace(projectRoot);
+ const descriptors = listRecords(projectRoot);
+ const dbPath = dbPathFor(projectRoot);
+ const tempPath = tempDbPathFor(projectRoot);
+ fs.rmSync(tempPath, { force: true });
+
+ const db = openProjection(tempPath);
+ try {
+ for (const descriptor of descriptors) {
+ let record;
+ try {
+ record = readRecord(descriptor.absolutePath);
+ } catch (cause) {
+ throw new GenesisError("RECORD_SCHEMA_INVALID", "Record failed its registered schema", {
+ path: descriptor.relativePath,
+ correction: "fix the YAML syntax or duplicate keys",
+ escalation: "builder",
+ cause,
+ });
+ }
+ validateProjectionRecord(registry, descriptor, record);
+ projectRecord(db, descriptor, record);
+ }
+
+ const consistency = projectionConsistency(db, descriptors);
+ if (!consistency.consistent) {
+ throw new GenesisError("PROJECTION_INCONSISTENT", "Projection row count does not match YAML records", {
+ path: "/projection_consistent",
+ correction: "Rebuild the projection from the current YAML records",
+ escalation: "builder",
+ });
+ }
+
+ const businessCount = db.prepare("SELECT COUNT(*) AS count FROM opportunities").get().count;
+ const recordCount = descriptors.length;
+ db.close();
+ fs.renameSync(tempPath, dbPath);
+ return { recordCount, businessCount };
+ } catch (error) {
+ db.close();
+ fs.rmSync(tempPath, { force: true });
+ throw error;
+ }
+}
diff --git a/src/storage/workspace.mjs b/src/storage/workspace.mjs
new file mode 100644
index 0000000..1221ffa
--- /dev/null
+++ b/src/storage/workspace.mjs
@@ -0,0 +1,162 @@
+import fs from "node:fs";
+import fsp from "node:fs/promises";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+
+import { GenesisError } from "../core/errors.mjs";
+
+export function workspacePaths(projectRoot) {
+ const root = path.resolve(projectRoot, ".genesis");
+ const records = path.join(root, "records");
+ return {
+ root,
+ records,
+ decisions: path.join(records, "decisions"),
+ experiments: path.join(records, "experiments"),
+ evidence: path.join(records, "evidence"),
+ db: path.join(root, "genesis.db"),
+ lock: path.join(root, "workspace.lock"),
+ };
+}
+
+function ensureDirectory(directory) {
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
+ fs.chmodSync(directory, 0o700);
+}
+
+export function ensureWorkspace(projectRoot) {
+ const paths = workspacePaths(projectRoot);
+ ensureDirectory(paths.root);
+ ensureDirectory(paths.records);
+ ensureDirectory(paths.decisions);
+ ensureDirectory(paths.experiments);
+ ensureDirectory(paths.evidence);
+ return paths;
+}
+
+function lockError(message, correction) {
+ return new GenesisError("WORKSPACE_LOCKED", message, {
+ path: "/workspace/lock",
+ correction,
+ escalation: "builder",
+ });
+}
+
+async function reclaimStaleLock(lockPath) {
+ let contents;
+ try {
+ contents = await fsp.readFile(lockPath, "utf8");
+ } catch (error) {
+ if (error?.code === "ENOENT") {
+ return true;
+ }
+ throw lockError(
+ "The existing Genesis workspace lock cannot be inspected safely",
+ "Verify the lock owner manually before removing .genesis/workspace.lock",
+ );
+ }
+
+ const [pidText, timestampText, ...extra] = contents.trim().split("\n");
+ const pid = Number(pidText);
+ const timestamp = Date.parse(timestampText);
+ if (!Number.isSafeInteger(pid) || pid <= 0 || !Number.isFinite(timestamp) || extra.length > 0) {
+ throw lockError(
+ "The existing Genesis workspace lock is malformed or ambiguous",
+ "Verify that no Genesis process is active, then remove .genesis/workspace.lock manually",
+ );
+ }
+
+ try {
+ process.kill(pid, 0);
+ throw lockError(
+ "The Genesis workspace is already locked by an active process",
+ `Wait for process ${pid} to finish before retrying`,
+ );
+ } catch (error) {
+ if (error instanceof GenesisError) {
+ throw error;
+ }
+ if (error?.code !== "ESRCH") {
+ throw lockError(
+ "The existing Genesis workspace lock owner cannot be classified safely",
+ "Verify the recorded process and lock manually before removing .genesis/workspace.lock",
+ );
+ }
+ }
+
+ const stalePath = `${lockPath}.stale.${randomUUID()}`;
+ try {
+ await fsp.rename(lockPath, stalePath);
+ } catch (error) {
+ if (error?.code === "ENOENT") {
+ return true;
+ }
+ throw lockError(
+ "A confirmed stale workspace lock could not be reclaimed",
+ "Verify that no Genesis process is active, then remove .genesis/workspace.lock manually",
+ );
+ }
+ await fsp.unlink(stalePath).catch(() => {});
+ return true;
+}
+
+async function acquireLock(lockPath) {
+ try {
+ return await fsp.open(lockPath, "wx", 0o600);
+ } catch (error) {
+ if (error?.code !== "EEXIST") {
+ throw error;
+ }
+ }
+
+ await reclaimStaleLock(lockPath);
+ try {
+ return await fsp.open(lockPath, "wx", 0o600);
+ } catch (error) {
+ if (error?.code === "EEXIST") {
+ throw lockError(
+ "The Genesis workspace was locked while reclaiming a stale lock",
+ "Wait for the current workspace operation to finish before retrying",
+ );
+ }
+ throw error;
+ }
+}
+
+export async function withWorkspaceLock(projectRoot, operation) {
+ const paths = ensureWorkspace(projectRoot);
+ let handle;
+ let operationError;
+ let result;
+
+ handle = await acquireLock(paths.lock);
+
+ try {
+ await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`);
+ await handle.sync();
+ result = await operation();
+ } catch (error) {
+ operationError = error;
+ } finally {
+ if (handle) {
+ await handle.close().catch(() => {});
+ }
+ }
+
+ let cleanupError;
+ try {
+ await fsp.unlink(paths.lock);
+ } catch (error) {
+ if (error?.code !== "ENOENT") {
+ cleanupError = error;
+ }
+ }
+
+ if (operationError) {
+ throw operationError;
+ }
+ if (cleanupError) {
+ throw cleanupError;
+ }
+ return result;
+}
diff --git a/src/storage/yaml-record-store.mjs b/src/storage/yaml-record-store.mjs
new file mode 100644
index 0000000..00689b0
--- /dev/null
+++ b/src/storage/yaml-record-store.mjs
@@ -0,0 +1,312 @@
+import fs from "node:fs";
+import fsp from "node:fs/promises";
+import path from "node:path";
+import { randomUUID } from "node:crypto";
+
+import YAML from "yaml";
+
+import { GenesisError } from "../core/errors.mjs";
+import { ensureWorkspace, workspacePaths } from "./workspace.mjs";
+
+const KIND_DIRECTORIES = new Map([
+ ["decision", "decisions"],
+ ["experiment", "experiments"],
+ ["evidence", "evidence"],
+]);
+const DIRECTORY_KINDS = new Map(Array.from(KIND_DIRECTORIES, ([kind, directory]) => [directory, kind]));
+
+const RECORD_FILE_PATTERN = /^(?.+)\.v(?\d{4})\.ya?ml$/;
+
+function recordDirectoryForKind(paths, kind) {
+ const directory = KIND_DIRECTORIES.get(kind);
+ if (!directory) {
+ throw new GenesisError("RECORD_KIND_INVALID", "Record kind is not supported", {
+ path: "/kind",
+ correction: "Use decision, experiment, or evidence",
+ escalation: "builder",
+ });
+ }
+
+ return paths[directory];
+}
+
+function recordPath(projectRoot, kind, id, version) {
+ const paths = workspacePaths(projectRoot);
+ const directory = recordDirectoryForKind(paths, kind);
+ const versionLabel = String(version).padStart(4, "0");
+ return path.join(directory, `${id}.v${versionLabel}.yaml`);
+}
+
+async function writeStagedYaml(stagedPath, value) {
+ const content = `${YAML.stringify(value)}\n`;
+ let handle;
+ try {
+ handle = await fsp.open(stagedPath, "wx", 0o600);
+ await handle.writeFile(content);
+ await handle.sync();
+ await handle.close();
+ handle = null;
+ } catch (error) {
+ if (handle) {
+ await handle.close().catch(() => {});
+ }
+ await fsp.unlink(stagedPath).catch(() => {});
+ throw error;
+ }
+}
+
+async function syncDirectory(directory) {
+ const handle = await fsp.open(directory, "r");
+ try {
+ await handle.sync();
+ } finally {
+ await handle.close();
+ }
+}
+
+function transactionDirectory(projectRoot) {
+ return path.join(workspacePaths(projectRoot).root, ".transactions");
+}
+
+function safeTransactionPath(projectRoot, relativePath) {
+ const recordsRoot = workspacePaths(projectRoot).records;
+ const absolutePath = path.resolve(projectRoot, relativePath);
+ if (!absolutePath.startsWith(`${recordsRoot}${path.sep}`)) {
+ throw new GenesisError("TRANSACTION_RECOVERY_REQUIRED", "Transaction journal contains an unsafe path", {
+ path: relativePath,
+ correction: "Inspect .genesis/.transactions manually and preserve canonical records",
+ escalation: "builder",
+ });
+ }
+ return absolutePath;
+}
+
+async function sameFile(leftPath, rightPath) {
+ try {
+ const [left, right] = await Promise.all([fsp.stat(leftPath), fsp.stat(rightPath)]);
+ return left.dev === right.dev && left.ino === right.ino;
+ } catch (error) {
+ if (error?.code === "ENOENT") {
+ return false;
+ }
+ throw error;
+ }
+}
+
+async function rollbackTransaction(projectRoot, entries) {
+ for (const entry of entries) {
+ const stagedPath = safeTransactionPath(projectRoot, entry.stagedPath);
+ const finalPath = safeTransactionPath(projectRoot, entry.finalPath);
+ if (await sameFile(stagedPath, finalPath)) {
+ await fsp.unlink(finalPath).catch((error) => {
+ if (error?.code !== "ENOENT") throw error;
+ });
+ }
+ await fsp.unlink(stagedPath).catch((error) => {
+ if (error?.code !== "ENOENT") throw error;
+ });
+ }
+}
+
+export async function recoverRecordTransactions(projectRoot) {
+ const directory = transactionDirectory(projectRoot);
+ if (!fs.existsSync(directory)) {
+ return;
+ }
+
+ for (const entry of await fsp.readdir(directory, { withFileTypes: true })) {
+ if (!entry.isFile() || !entry.name.endsWith(".json")) {
+ continue;
+ }
+ const journalPath = path.join(directory, entry.name);
+ let journal;
+ try {
+ journal = JSON.parse(await fsp.readFile(journalPath, "utf8"));
+ if (!Number.isSafeInteger(journal.pid) || journal.pid <= 0 || !Array.isArray(journal.entries)) {
+ throw new Error("pid and entries are required");
+ }
+ try {
+ process.kill(journal.pid, 0);
+ continue;
+ } catch (error) {
+ if (error?.code !== "ESRCH") {
+ throw error;
+ }
+ }
+ await rollbackTransaction(projectRoot, journal.entries);
+ await fsp.unlink(journalPath);
+ } catch (cause) {
+ throw new GenesisError("TRANSACTION_RECOVERY_REQUIRED", "An interrupted record transaction could not be recovered safely", {
+ path: path.relative(projectRoot, journalPath),
+ correction: "Inspect the transaction journal and canonical YAML records before retrying",
+ escalation: "builder",
+ cause,
+ });
+ }
+ }
+}
+
+function walkRecords(directory, projectRoot, results) {
+ if (!fs.existsSync(directory)) {
+ return;
+ }
+
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const absolutePath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ walkRecords(absolutePath, projectRoot, results);
+ continue;
+ }
+
+ if (!entry.isFile() || entry.name.includes(".tmp")) {
+ continue;
+ }
+
+ const match = entry.name.match(RECORD_FILE_PATTERN);
+ if (!match) {
+ continue;
+ }
+
+ const relativePath = path.relative(projectRoot, absolutePath);
+ const relativeSegments = path.relative(path.join(projectRoot, ".genesis", "records"), absolutePath).split(path.sep);
+ const kind = DIRECTORY_KINDS.get(relativeSegments[0]);
+ if (!kind) {
+ continue;
+ }
+ results.push({
+ kind,
+ id: match.groups.id,
+ version: Number(match.groups.version),
+ absolutePath,
+ relativePath,
+ });
+ }
+}
+
+export async function writeRecord({ projectRoot, kind, id, version, value }) {
+ const [saved] = await writeRecords({
+ projectRoot,
+ records: [{ kind, id, version, value }],
+ });
+ return saved;
+}
+
+export async function writeRecords({ projectRoot, records }) {
+ ensureWorkspace(projectRoot);
+ await recoverRecordTransactions(projectRoot);
+ if (!Array.isArray(records) || records.length === 0) {
+ return [];
+ }
+
+ const transactionId = randomUUID();
+ const transactionEntries = records.map(({ kind, id, version, value }) => {
+ const finalPath = recordPath(projectRoot, kind, id, version);
+ const stagedPath = path.join(
+ path.dirname(finalPath),
+ `.${path.basename(finalPath)}.${transactionId}.staged`,
+ );
+ return { kind, id, version, value, finalPath, stagedPath };
+ });
+ const uniqueFinalPaths = new Set(transactionEntries.map((entry) => entry.finalPath));
+ if (uniqueFinalPaths.size !== transactionEntries.length) {
+ throw new GenesisError("RECORD_VERSION_EXISTS", "Record batch contains duplicate version paths", {
+ path: "/records",
+ correction: "Assign one unique version to every record in the command",
+ escalation: "builder",
+ });
+ }
+
+ const journalDirectory = transactionDirectory(projectRoot);
+ fs.mkdirSync(journalDirectory, { recursive: true, mode: 0o700 });
+ fs.chmodSync(journalDirectory, 0o700);
+ const journalPath = path.join(journalDirectory, `${transactionId}.json`);
+ const journal = {
+ id: transactionId,
+ pid: process.pid,
+ createdAt: new Date().toISOString(),
+ entries: transactionEntries.map((entry) => ({
+ stagedPath: path.relative(projectRoot, entry.stagedPath),
+ finalPath: path.relative(projectRoot, entry.finalPath),
+ })),
+ };
+
+ try {
+ for (const entry of transactionEntries) {
+ await writeStagedYaml(entry.stagedPath, entry.value);
+ }
+ const journalHandle = await fsp.open(journalPath, "wx", 0o600);
+ try {
+ await journalHandle.writeFile(`${JSON.stringify(journal, null, 2)}\n`);
+ await journalHandle.sync();
+ } finally {
+ await journalHandle.close();
+ }
+ await syncDirectory(journalDirectory);
+
+ for (const entry of transactionEntries) {
+ try {
+ await fsp.link(entry.stagedPath, entry.finalPath);
+ } catch (cause) {
+ if (cause?.code === "EEXIST") {
+ throw new GenesisError("RECORD_VERSION_EXISTS", "Record version already exists", {
+ path: entry.finalPath,
+ correction: "Use the next version number for the record instead of overwriting an existing file",
+ escalation: "builder",
+ cause,
+ });
+ }
+ throw cause;
+ }
+ }
+ for (const directory of new Set(transactionEntries.map((entry) => path.dirname(entry.finalPath)))) {
+ await syncDirectory(directory);
+ }
+
+ await fsp.unlink(journalPath);
+ await syncDirectory(journalDirectory);
+ for (const entry of transactionEntries) {
+ await fsp.unlink(entry.stagedPath).catch(() => {});
+ }
+ } catch (error) {
+ try {
+ await rollbackTransaction(projectRoot, journal.entries);
+ await fsp.unlink(journalPath).catch(() => {});
+ } catch (cause) {
+ throw new GenesisError("TRANSACTION_RECOVERY_REQUIRED", "Record transaction rollback did not complete safely", {
+ path: path.relative(projectRoot, journalPath),
+ correction: "Inspect the transaction journal and canonical YAML records before retrying",
+ escalation: "builder",
+ cause,
+ });
+ }
+ throw error;
+ }
+
+ return transactionEntries.map((entry) => ({
+ absolutePath: entry.finalPath,
+ relativePath: path.relative(projectRoot, entry.finalPath),
+ }));
+}
+
+export function readRecord(absolutePath) {
+ return YAML.parse(fs.readFileSync(absolutePath, "utf8"));
+}
+
+export function listRecords(projectRoot) {
+ const paths = ensureWorkspace(projectRoot);
+ const results = [];
+ walkRecords(paths.records, projectRoot, results);
+ return results.sort((left, right) => {
+ const kindOrder = left.kind.localeCompare(right.kind);
+ if (kindOrder !== 0) {
+ return kindOrder;
+ }
+
+ const idOrder = left.id.localeCompare(right.id);
+ if (idOrder !== 0) {
+ return idOrder;
+ }
+
+ return left.version - right.version;
+ });
+}
diff --git a/templates/decision-record.yaml b/templates/decision-record.yaml
index 2849f51..e30cddb 100644
--- a/templates/decision-record.yaml
+++ b/templates/decision-record.yaml
@@ -12,6 +12,7 @@ related_records: [example-experiment-001]
privacy_classification: internal
immutable_history_refs: [history://example/decision-001/v1]
example_only: true
+target_customer: Operations leaders responsible for the example high-friction task.
problem: Decide whether the example opportunity should enter a bounded validation experiment.
hypothesis: The identified customer will complete the proposed high-friction task with the prototype.
confidence: 0.55
diff --git a/templates/experiment-record.yaml b/templates/experiment-record.yaml
index cdf3179..0787fe4 100644
--- a/templates/experiment-record.yaml
+++ b/templates/experiment-record.yaml
@@ -7,6 +7,8 @@ updated_at: 2026-07-24T10:00:00Z
owner: research
affected_business: example-business
status: closed
+subtype: validation
+validation_outcome: passed
evidence_references: [evidence://example/experiment-results]
related_records: [example-decision-001, example-experience-001]
privacy_classification: internal
@@ -45,3 +47,5 @@ results: Three of five qualified participants completed the task unaided.
reflection: The threshold passed narrowly; counterevidence remains material.
outcome: pivot
experience_reference: example-experience-001
+confidence_update: 0.60
+decision_outcome: pivot
diff --git a/tests/cli-core.test.mjs b/tests/cli-core.test.mjs
new file mode 100644
index 0000000..10cb9c3
--- /dev/null
+++ b/tests/cli-core.test.mjs
@@ -0,0 +1,45 @@
+import assert from "node:assert/strict";
+import { PassThrough } from "node:stream";
+import test from "node:test";
+import { createPrompter } from "../src/cli/prompter.mjs";
+import { formatError, GenesisError } from "../src/core/errors.mjs";
+import { normalizeBusinessId, versionFileName } from "../src/core/ids.mjs";
+import { suggestionsFor } from "../src/core/suggestions.mjs";
+
+test("business IDs and version paths are deterministic", () => {
+ assert.equal(normalizeBusinessId(" Local Bakery CRM "), "local-bakery-crm");
+ assert.equal(versionFileName("local-bakery-crm-decision", 2), "local-bakery-crm-decision.v0002.yaml");
+ assert.throws(() => normalizeBusinessId("---"), { code: "BUSINESS_ID_INVALID" });
+});
+
+test("errors render actionable fail-closed fields", () => {
+ const error = new GenesisError("RECORD_SCHEMA_INVALID", "Record is invalid", {
+ path: "/confidence", correction: "Enter a number from 0 to 1", escalation: "human_authority",
+ });
+ assert.match(formatError(error), /RECORD_SCHEMA_INVALID/);
+ assert.match(formatError(error), /Enter a number from 0 to 1/);
+});
+
+test("suggestions are stable, offline, and immutable", () => {
+ const first = suggestionsFor("validation_methods");
+ assert.deepEqual(first, suggestionsFor("validation_methods"));
+ assert.equal(Object.isFrozen(first), true);
+ assert.deepEqual(suggestionsFor("constructor"), []);
+ assert.equal(Object.isFrozen(suggestionsFor("unsupported_topic")), true);
+});
+
+test("interactive choices reprompt instead of accepting unknown input", async () => {
+ const input = new PassThrough();
+ const output = new PassThrough();
+ let rendered = "";
+ output.on("data", (chunk) => { rendered += chunk; });
+ const prompter = createPrompter({ input, output });
+ const selected = prompter.choose("Evidence stance:", ["support", "contradict"]);
+ input.write("not-a-choice\n");
+ await new Promise((resolve) => setImmediate(resolve));
+ input.write("2\n");
+ assert.equal(await selected, "contradict");
+ assert.match(rendered, /Invalid choice/);
+ input.end();
+ await prompter.close();
+});
diff --git a/tests/cli-integration.test.mjs b/tests/cli-integration.test.mjs
new file mode 100644
index 0000000..90d970c
--- /dev/null
+++ b/tests/cli-integration.test.mjs
@@ -0,0 +1,286 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { runCli } from "../src/cli/run-cli.mjs";
+import { workspacePaths } from "../src/storage/workspace.mjs";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+const CLOCK = () => new Date("2026-07-17T12:00:00Z");
+
+function makeProjectRoot() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "genesis-cli-"));
+}
+
+function cleanupProjectRoot(projectRoot) {
+ fs.rmSync(projectRoot, { recursive: true, force: true });
+}
+
+function createBuffer() {
+ let text = "";
+ return {
+ write(chunk) {
+ text += chunk;
+ return true;
+ },
+ toString() {
+ return text;
+ },
+ };
+}
+
+function createScriptedPrompter(answers, output) {
+ let index = 0;
+
+ function nextAnswer(fallback = "") {
+ const answer = index < answers.length ? answers[index] : fallback;
+ index += 1;
+ return answer;
+ }
+
+ return {
+ async ask(question) {
+ output.write(question);
+ const answer = nextAnswer("");
+ output.write(`${answer}\n`);
+ return answer;
+ },
+ async choose(question, choices) {
+ output.write(`${question}\n`);
+ for (const [index_, choice] of choices.entries()) {
+ output.write(` ${index_ + 1}. ${typeof choice === "string" ? choice : choice.label}\n`);
+ }
+ output.write("> ");
+ const answer = nextAnswer("");
+ output.write(`${answer}\n`);
+ if (!answer) {
+ return typeof choices[0] === "string" ? choices[0] : choices[0]?.value;
+ }
+
+ const numeric = Number.parseInt(answer, 10);
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= choices.length) {
+ const selected = choices[numeric - 1];
+ return typeof selected === "string" ? selected : selected.value;
+ }
+
+ const selected = choices.find((choice) => (
+ (typeof choice === "string" ? choice : choice.label) === answer
+ || (typeof choice === "string" ? choice : choice.value) === answer
+ ));
+ if (selected) {
+ return typeof selected === "string" ? selected : selected.value;
+ }
+
+ return typeof choices[0] === "string" ? choices[0] : choices[0]?.value;
+ },
+ async confirm(question) {
+ output.write(question);
+ const answer = nextAnswer("n");
+ output.write(`${answer}\n`);
+ return ["y", "yes", "true", "1"].includes(String(answer).trim().toLowerCase());
+ },
+ async close() {},
+ };
+}
+
+function runOutput(text) {
+ return text.replaceAll("\r\n", "\n");
+}
+
+test("CLI runs start-business, add-evidence, status, plan-experiment, and rebuild-index", { concurrency: false }, async () => {
+ const projectRoot = makeProjectRoot();
+ const output = createBuffer();
+ const scriptedPrompter = createScriptedPrompter([
+ "bakery",
+ "Independent bakery owners",
+ "Weekly order reconciliation takes too long",
+ "A clearer order view will reduce reconciliation time",
+ "0.55",
+ "interview://owner-1",
+ "Owner spends two hours on reconciliation every week",
+ "1",
+ "Interview note",
+ "1",
+ "Two owners object to learning curve",
+ "keep_manual_process,use_spreadsheet_template",
+ "Weekly reconciliation takes less than one hour",
+ "weekly_reconciliation_minutes",
+ "run_bounded_validation",
+ "research",
+ "2026-07-24T12:00:00Z",
+ "y",
+ "interview://owner-2",
+ "A second owner also wants the same flow",
+ "contradict",
+ "Interview note",
+ "internal",
+ "y",
+ "",
+ "research",
+ "Owners currently take two hours each week",
+ "Compare observed time with the two-hour baseline",
+ "sum_reconciliation_minutes_divided_by_sessions",
+ "qualified_bakery_owners",
+ "completed_reconciliation_sessions",
+ "observed_session_log",
+ "Median reconciliation time is below one hour",
+ "median_time_reduction_at_least_60_minutes",
+ "median_time_is_not_reduced",
+ "participant_harm,privacy_incident",
+ "0",
+ "8",
+ "7",
+ "internal",
+ "1",
+ "2026-07-17T12:00:00Z",
+ "scale,pivot,learning_lab,archive,kill",
+ "y",
+ ], output);
+
+ try {
+ const startExit = await runCli(["start-business"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(startExit, 0);
+
+ const addExit = await runCli(["add-evidence", "bakery"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(addExit, 0);
+
+ const statusExit = await runCli(["status", "bakery"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(statusExit, 0);
+
+ const planExit = await runCli(["plan-experiment", "bakery"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(planExit, 0);
+
+ fs.rmSync(workspacePaths(projectRoot).db, { force: true });
+
+ const rebuildExit = await runCli(["rebuild-index"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(rebuildExit, 0);
+
+ const statusAfterRebuildExit = await runCli(["status", "bakery"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(statusAfterRebuildExit, 0);
+
+ const text = runOutput(output.toString());
+ assert.equal(text.includes("Offline suggestion — not evidence:"), true);
+ assert.equal(text.includes("support"), true);
+ assert.equal(text.includes("contradict"), true);
+ assert.equal(text.includes("Save this immutable record? [y/N]"), true);
+ assert.equal(text.indexOf("Proposed record:") >= 0, true);
+ assert.equal(text.includes("State: discover"), true);
+ assert.equal(text.includes("State: approval_pending"), true);
+ assert.equal(text.includes("Decision versions: 2"), true);
+ assert.equal(text.includes("Experiment versions: 1"), true);
+ assert.equal(text.includes("Evidence count: 2"), true);
+ assert.equal(text.includes("Blocked commands: none"), true);
+ assert.equal(text.includes("Projection consistent: yes"), true);
+ assert.equal(text.includes("Records rebuilt: 5"), true);
+ assert.equal(text.includes("Businesses rebuilt: 1"), true);
+ assert.equal(text.includes("Decision versions: 2"), true);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("CLI returns 2 for unknown commands and 1 for validation errors", { concurrency: false }, async () => {
+ const projectRoot = makeProjectRoot();
+ const output = createBuffer();
+ try {
+ const unknownExit = await runCli(["frobnicate"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ output,
+ errorOutput: output,
+ prompter: createScriptedPrompter([], output),
+ });
+ assert.equal(unknownExit, 2);
+ assert.equal(runOutput(output.toString()).includes("Usage:"), true);
+
+ output.write("\n");
+
+ const validationExit = await runCli(["status", "bakery"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ output,
+ errorOutput: output,
+ prompter: createScriptedPrompter([], output),
+ });
+ assert.equal(validationExit, 1);
+ const text = runOutput(output.toString());
+ assert.equal(text.includes("BUSINESS_NOT_FOUND"), true);
+ assert.equal(text.includes("Path: /business_id"), true);
+ assert.equal(text.includes("Correction:"), true);
+ assert.equal(text.includes("Escalation:"), true);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("CLI rejects numeric input with trailing non-numeric characters", { concurrency: false }, async () => {
+ const projectRoot = makeProjectRoot();
+ const output = createBuffer();
+ try {
+ const exit = await runCli(["start-business"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ output,
+ errorOutput: output,
+ prompter: createScriptedPrompter([
+ "bakery",
+ "Independent bakery owners",
+ "Weekly order reconciliation takes too long",
+ "A clearer order view will reduce reconciliation time",
+ "0.55trailing",
+ ], output),
+ });
+ assert.equal(exit, 1);
+ assert.match(output.toString(), /INPUT_INVALID/);
+ assert.equal(fs.existsSync(path.join(projectRoot, ".genesis", "records")), false);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
diff --git a/tests/configuration.test.mjs b/tests/configuration.test.mjs
index 7fe9f45..48a0795 100644
--- a/tests/configuration.test.mjs
+++ b/tests/configuration.test.mjs
@@ -104,6 +104,7 @@ test("Constitution places Human Authority above CEO and links normative policy",
test("configuration guide identifies genesis.yaml and YAML precedence", async () => {
const policySet = await loadPolicySet(ROOT);
const guide = policySet.documents.get("configuration_guide");
+ assert.match(guide, /CLI runtime guide/);
assert.match(guide, /\[genesis\.yaml\]\(genesis\.yaml\)/);
assert.match(guide, /YAML wins over Markdown/i);
assert.match(guide, /non-normative/i);
@@ -182,3 +183,33 @@ test("GitHub Actions runs the locked Genesis validation gate", async () => {
assert.match(workflow, new RegExp(`- run: ${command.replaceAll(" ", "\\s")}`));
}
});
+
+test("README documents the offline CLI, files, recovery, and limits", async () => {
+ const readme = await readFile(path.join(ROOT, "README.md"), "utf8");
+ for (const command of [
+ "genesis start-business",
+ "genesis add-evidence ",
+ "genesis status ",
+ "genesis plan-experiment ",
+ "genesis rebuild-index",
+ ]) {
+ assert.match(readme, new RegExp(command.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
+ }
+
+ assert.match(readme, /\.genesis\//);
+ assert.match(readme, /approval_pending/);
+ assert.match(readme, /YAML/i);
+ assert.match(readme, /SQLite/i);
+ assert.match(readme, /rebuild-index/);
+ assert.match(readme, /npm ci/);
+ assert.match(readme, /npm start/);
+ assert.match(readme, /node bin\/genesis\.mjs/);
+ assert.match(readme, /npm link/);
+ assert.match(readme, /The CLI stops at `approval_pending`/);
+ assert.match(readme, /does not automatically research, contact customers, execute experiments, build products, deploy software, bill customers, or operate a business/i);
+});
+
+test("package.json exposes a direct start command", async () => {
+ const packageJson = JSON.parse(await readFile(path.join(ROOT, "package.json"), "utf8"));
+ assert.equal(packageJson.scripts.start, "node bin/genesis.mjs");
+});
diff --git a/tests/discovery-workflow.test.mjs b/tests/discovery-workflow.test.mjs
new file mode 100644
index 0000000..b5d606b
--- /dev/null
+++ b/tests/discovery-workflow.test.mjs
@@ -0,0 +1,333 @@
+import assert from "node:assert/strict";
+import { readFile } from "node:fs/promises";
+import path from "node:path";
+import test from "node:test";
+
+import YAML from "yaml";
+
+import {
+ buildStatus,
+ evaluateDiscoverGate,
+ experimentCompleteness,
+} from "../src/core/discovery-workflow.mjs";
+import { calculateMetrics } from "../src/core/metrics.mjs";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+const WORKFLOW_PATH = path.join(ROOT, "config", "workflows", "experiment-lifecycle.yaml");
+
+async function loadRequiredPaths() {
+ const workflow = YAML.parse(await readFile(WORKFLOW_PATH, "utf8"));
+ return workflow.preregistration_required_fields.map((field) => `/${field.replaceAll(".", "/")}`);
+}
+
+function makeDecision(overrides = {}) {
+ return {
+ target_customer: "Independent bakery owners",
+ problem: "Weekly order reconciliation takes too long",
+ hypothesis: "A clearer order view will reduce reconciliation time",
+ ...overrides,
+ };
+}
+
+function makeExperiment(overrides = {}) {
+ return {
+ id: "bakery-experiment",
+ record_type: "experiment_record",
+ schema_version: "1.0.0",
+ policy_version: "2.0.0",
+ created_at: "2026-07-16T00:00:00Z",
+ updated_at: "2026-07-16T00:00:00Z",
+ owner: "research",
+ affected_business: "bakery",
+ status: "draft",
+ subtype: "validation",
+ validation_outcome: "pending",
+ evidence_references: ["evidence://bakery/interview-1"],
+ related_records: ["bakery-decision"],
+ privacy_classification: "internal",
+ immutable_history_refs: ["records/experiments/bakery-experiment.v0001.yaml"],
+ problem: "Determine whether the proposed order view reduces reconciliation time",
+ supported_decision: "bakery-decision",
+ hypothesis: "Bakery owners complete reconciliation in less than one hour",
+ confidence: 0.55,
+ evidence: ["evidence://bakery/interview-1"],
+ counterevidence: [],
+ baseline: "Owners currently take two hours each week",
+ comparison_method: "Compare observed time with the two-hour baseline",
+ metric: {
+ formula: "sum_reconciliation_minutes_divided_by_sessions",
+ population: "qualified_bakery_owners",
+ denominator: "completed_reconciliation_sessions",
+ data_source: "observed_session_log",
+ },
+ expected_outcome: "Median reconciliation time is below one hour",
+ minimum_meaningful_effect: "median_time_reduction_at_least_60_minutes",
+ failure_conditions: ["median_time_is_not_reduced"],
+ stop_conditions: ["participant_harm", "privacy_incident"],
+ limits: {
+ cash_usd: 0,
+ labor_hours: 8,
+ duration_days: 7,
+ data_classes: ["internal"],
+ risk_level: "low",
+ },
+ decision_date: "2026-07-18T00:00:00Z",
+ allowed_outcomes: ["scale", "pivot", "learning_lab", "archive", "kill"],
+ approval_references: [],
+ ...overrides,
+ };
+}
+
+test("Discover gate blocks missing target customer, problem, hypothesis, and zero evidence separately", () => {
+ const evidence = [{
+ id: "bakery-evidence-001",
+ business_id: "bakery",
+ collected_at: "2026-07-14T00:00:00Z",
+ source_reference: "interview://owner-1",
+ summary: "Owner spends two hours on reconciliation every week",
+ stance: "support",
+ provenance: "Interview note",
+ privacy_classification: "internal",
+ }];
+
+ for (const [field, pathName, evidenceInput, overrides] of [
+ ["target_customer", "/target_customer", evidence, { target_customer: " " }],
+ ["problem", "/problem", evidence, { problem: " " }],
+ ["hypothesis", "/hypothesis", evidence, { hypothesis: null }],
+ ["evidence", "/evidence", [], {}],
+ ]) {
+ const result = evaluateDiscoverGate({
+ decision: makeDecision(overrides),
+ evidence: evidenceInput,
+ });
+
+ assert.equal(result.passed, false);
+ assert.deepEqual(result.blockers, [{
+ code: "DISCOVER_GATE_BLOCKED",
+ path: pathName,
+ correction: field === "evidence"
+ ? "Add at least one confirmed evidence entry"
+ : `Provide ${field.replaceAll("_", " ")}`,
+ escalation: "builder",
+ }]);
+ }
+});
+
+test("Discover gate passes when the decision has the required fields and one evidence entry exists", () => {
+ const evidence = [{
+ id: "bakery-evidence-001",
+ business_id: "bakery",
+ collected_at: "2026-07-14T00:00:00Z",
+ source_reference: "interview://owner-1",
+ summary: "Owner spends two hours on reconciliation every week",
+ stance: "support",
+ provenance: "Interview note",
+ privacy_classification: "internal",
+ }];
+
+ assert.deepEqual(
+ evaluateDiscoverGate({
+ decision: makeDecision(),
+ evidence,
+ }),
+ { passed: true, blockers: [] },
+ );
+});
+
+test("experimentCompleteness reads the workflow preregistration requirements from YAML", async () => {
+ const requiredPaths = await loadRequiredPaths();
+ const result = experimentCompleteness(makeExperiment());
+
+ assert.deepEqual(result.required, requiredPaths);
+ assert.equal(result.complete, true);
+ assert.equal(result.ratio, 1);
+ assert.deepEqual(result.missing, []);
+ assert.equal(result.present.length, requiredPaths.length);
+});
+
+test("experimentCompleteness reports exact JSON-pointer-like missing paths", () => {
+ const result = experimentCompleteness(makeExperiment({
+ problem: "",
+ metric: {
+ formula: " ",
+ population: "qualified_bakery_owners",
+ denominator: "completed_reconciliation_sessions",
+ data_source: "observed_session_log",
+ },
+ limits: {
+ cash_usd: null,
+ labor_hours: 8,
+ duration_days: 7,
+ data_classes: [],
+ risk_level: "low",
+ },
+ decision_date: undefined,
+ allowed_outcomes: [],
+ }));
+
+ assert.equal(result.complete, false);
+ assert.equal(result.ratio < 1, true);
+ assert.deepEqual(result.missing, [
+ "/problem",
+ "/metric/formula",
+ "/limits/cash_usd",
+ "/limits/data_classes",
+ "/decision_date",
+ "/allowed_outcomes",
+ ]);
+});
+
+test("buildStatus reports approval_pending and supported metrics for a complete draft experiment", () => {
+ const decisionVersions = [
+ {
+ version: 1,
+ created_at: "2026-07-14T00:00:00Z",
+ confidence: 0.4,
+ target_customer: "Independent bakery owners",
+ problem: "Weekly order reconciliation takes too long",
+ hypothesis: "A clearer order view will reduce reconciliation time",
+ },
+ {
+ version: 2,
+ created_at: "2026-07-15T00:00:00Z",
+ confidence: 0.6,
+ target_customer: "Independent bakery owners",
+ problem: "Weekly order reconciliation takes too long",
+ hypothesis: "A clearer order view will reduce reconciliation time",
+ },
+ ];
+ const experimentVersions = [makeExperiment()];
+ const evidence = [
+ {
+ id: "bakery-evidence-001",
+ business_id: "bakery",
+ collected_at: "2026-07-14T00:00:00Z",
+ source_reference: "interview://owner-1",
+ summary: "Owner spends two hours on reconciliation every week",
+ stance: "support",
+ provenance: "Interview note",
+ privacy_classification: "internal",
+ },
+ {
+ id: "bakery-evidence-002",
+ business_id: "bakery",
+ collected_at: "2026-07-15T00:00:00Z",
+ source_reference: "interview://owner-2",
+ summary: "Another owner also wants the same flow",
+ stance: "support",
+ provenance: "Interview note",
+ privacy_classification: "internal",
+ },
+ {
+ id: "bakery-evidence-003",
+ business_id: "bakery",
+ collected_at: "2026-07-15T00:00:00Z",
+ source_reference: "interview://owner-3",
+ summary: "A separate owner prefers the current process",
+ stance: "contradict",
+ provenance: "Interview note",
+ privacy_classification: "internal",
+ },
+ ];
+ const blockedCommands = [{ code: "DISCOVER_GATE_BLOCKED" }];
+
+ const metrics = calculateMetrics({
+ decisionVersions,
+ experimentVersions,
+ evidence,
+ blockedCommands,
+ consistency: { consistent: true },
+ now: "2026-07-17T00:00:00Z",
+ });
+
+ assert.deepEqual(metrics, {
+ supporting_evidence_count: 2,
+ contradicting_evidence_count: 1,
+ discover_days: 3,
+ time_to_validation_plan_days: 2,
+ preregistration_completeness: 1,
+ confidence_history: [0.4, 0.6],
+ blocked_commands_by_code: { DISCOVER_GATE_BLOCKED: 1 },
+ projection_consistent: true,
+ });
+
+ const status = buildStatus({
+ decisionVersions,
+ experimentVersions,
+ evidence,
+ blockedCommands,
+ consistency: { consistent: true },
+ now: "2026-07-17T00:00:00Z",
+ });
+
+ assert.equal(status.state, "approval_pending");
+ assert.equal(status.next_command, "status");
+ assert.equal(status.experiment_completeness.ratio, 1);
+ assert.deepEqual(status.metrics, metrics);
+});
+
+test("buildStatus reports missing preregistration paths for incomplete drafts", () => {
+ const status = buildStatus({
+ decisionVersions: [{
+ version: 1,
+ created_at: "2026-07-14T00:00:00Z",
+ confidence: 0.4,
+ target_customer: "Independent bakery owners",
+ problem: "Weekly order reconciliation takes too long",
+ hypothesis: "A clearer order view will reduce reconciliation time",
+ }],
+ experimentVersions: [makeExperiment({
+ problem: "",
+ metric: {
+ formula: " ",
+ population: "qualified_bakery_owners",
+ denominator: "completed_reconciliation_sessions",
+ data_source: "observed_session_log",
+ },
+ limits: {
+ cash_usd: null,
+ labor_hours: 8,
+ duration_days: 7,
+ data_classes: [],
+ risk_level: "low",
+ },
+ decision_date: undefined,
+ allowed_outcomes: [],
+ })],
+ evidence: [{
+ id: "bakery-evidence-001",
+ business_id: "bakery",
+ collected_at: "2026-07-14T00:00:00Z",
+ source_reference: "interview://owner-1",
+ summary: "Owner spends two hours on reconciliation every week",
+ stance: "support",
+ provenance: "Interview note",
+ privacy_classification: "internal",
+ }],
+ blockedCommands: [],
+ consistency: true,
+ now: "2026-07-17T00:00:00Z",
+ });
+
+ assert.equal(status.state, "discover");
+ assert.equal(status.next_command, "status");
+ assert.equal(status.experiment_completeness.complete, false);
+ assert.equal(status.experiment_completeness.missing.includes("/metric/formula"), true);
+ assert.equal(status.experiment_completeness.missing.includes("/limits/cash_usd"), true);
+ assert.equal(status.experiment_completeness.missing.includes("/decision_date"), true);
+});
+
+test("buildStatus preserves non-draft experiment lifecycle states", () => {
+ for (const experimentStatus of ["active", "closed", "superseded"]) {
+ const status = buildStatus({
+ decisionVersions: [makeDecision({ version: 1, confidence: 0.5 })],
+ experimentVersions: [makeExperiment({ status: experimentStatus })],
+ evidence: [{ stance: "support" }],
+ consistency: true,
+ now: "2026-07-17T00:00:00Z",
+ });
+
+ assert.equal(status.state, experimentStatus);
+ assert.equal(status.next_command, "status");
+ }
+});
diff --git a/tests/genesis-service.test.mjs b/tests/genesis-service.test.mjs
new file mode 100644
index 0000000..8fffcd5
--- /dev/null
+++ b/tests/genesis-service.test.mjs
@@ -0,0 +1,301 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+
+import { createSchemaRegistry } from "../src/core/schema-registry.mjs";
+import { buildDecisionRecord, versionDecisionRecord } from "../src/core/record-builders.mjs";
+import { listRecords, readRecord, writeRecord } from "../src/storage/yaml-record-store.mjs";
+import { workspacePaths } from "../src/storage/workspace.mjs";
+
+import { createGenesisService } from "../src/application/genesis-service.mjs";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+const clock = () => new Date("2026-07-17T12:00:00Z");
+
+function makeProjectRoot() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "genesis-service-"));
+}
+
+function cleanupProjectRoot(projectRoot) {
+ fs.rmSync(projectRoot, { recursive: true, force: true });
+}
+
+function startBusinessInput(overrides = {}) {
+ return {
+ business_id: "bakery",
+ owner: "research",
+ target_customer: "Independent bakery owners",
+ problem: "Weekly order reconciliation takes too long",
+ hypothesis: "A clearer order view will reduce reconciliation time",
+ confidence: 0.55,
+ source_reference: "interview://owner-1",
+ summary: "Owner spends two hours on reconciliation every week",
+ stance: "support",
+ provenance: "Interview note",
+ privacy_classification: "internal",
+ counterevidence: ["Interview objection about learning curve"],
+ alternatives: ["keep_manual_process", "use_spreadsheet_template"],
+ expected_outcome: "Weekly reconciliation takes less than one hour",
+ metric: "weekly_reconciliation_minutes",
+ decision: "run_bounded_validation",
+ review_date: "2026-07-24T12:00:00Z",
+ ...overrides,
+ };
+}
+
+function addEvidenceInput(overrides = {}) {
+ return {
+ source_reference: "interview://owner-2",
+ summary: "A second owner also wants the same flow",
+ stance: "contradict",
+ provenance: "Interview note",
+ privacy_classification: "internal",
+ decision_changes: { confidence: 0.65 },
+ ...overrides,
+ };
+}
+
+function experimentInput(overrides = {}) {
+ return {
+ owner: "research",
+ baseline: "Owners currently take two hours each week",
+ comparison_method: "Compare observed time with the two-hour baseline",
+ metric: {
+ formula: "sum_reconciliation_minutes_divided_by_sessions",
+ population: "qualified_bakery_owners",
+ denominator: "completed_reconciliation_sessions",
+ data_source: "observed_session_log",
+ },
+ expected_outcome: "Median reconciliation time is below one hour",
+ minimum_meaningful_effect: "median_time_reduction_at_least_60_minutes",
+ failure_conditions: ["median_time_is_not_reduced"],
+ stop_conditions: ["participant_harm", "privacy_incident"],
+ limits: {
+ cash_usd: 0,
+ labor_hours: 8,
+ duration_days: 7,
+ data_classes: ["internal"],
+ risk_level: "low",
+ },
+ decision_date: "2026-07-17T12:00:00Z",
+ allowed_outcomes: ["scale", "pivot", "learning_lab", "archive", "kill"],
+ ...overrides,
+ };
+}
+
+function createService(projectRoot, confirm = async () => true) {
+ return createGenesisService({
+ projectRoot,
+ repoRoot: ROOT,
+ clock,
+ confirm,
+ });
+}
+
+async function runSection(label, fn) {
+ try {
+ await fn();
+ } catch (error) {
+ console.error(`FAILED SECTION: ${label}`);
+ console.error(error);
+ throw error;
+ }
+}
+
+await runSection("startBusiness", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const service = createService(projectRoot);
+
+ const result = await service.startBusiness(startBusinessInput());
+
+ assert.equal(result.changed, true);
+ assert.equal(result.state, "discover");
+ assert.equal(result.projection_stale, false);
+ assert.equal(result.records.length, 2);
+ assert.equal(result.records[0].record_type, undefined);
+ assert.equal(result.records[1].record_type, "decision_record");
+
+ const records = listRecords(projectRoot);
+ assert.equal(records.length, 2);
+
+ const decisionPath = records.find((record) => record.kind === "decision").absolutePath;
+ const evidencePath = records.find((record) => record.kind === "evidence").absolutePath;
+ const decision = readRecord(decisionPath);
+ const evidence = readRecord(evidencePath);
+
+ assert.equal(evidence.stance, "support");
+ assert.equal(decision.id, "bakery-decision");
+ assert.deepEqual(decision.immutable_history_refs, ["records/decisions/bakery-decision.v0001.yaml"]);
+
+ const status = await service.status("bakery");
+ assert.equal(status.state, "discover");
+ assert.equal(status.next_command, "plan-experiment");
+ assert.equal(status.decision_versions, 1);
+ assert.equal(status.experiment_versions, 0);
+ assert.equal(status.evidence_count, 1);
+ assert.equal(status.metrics.supporting_evidence_count, 1);
+ assert.equal(status.projection_consistent, true);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+await runSection("addEvidence", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const service = createService(projectRoot);
+ await service.startBusiness(startBusinessInput());
+
+ const result = await service.addEvidence("bakery", addEvidenceInput());
+
+ assert.equal(result.changed, true);
+ assert.equal(result.state, "discover");
+ assert.equal(result.projection_stale, false);
+
+ const records = listRecords(projectRoot);
+ assert.equal(records.length, 4);
+
+ const decisionV2 = readRecord(records.filter((record) => record.kind === "decision").at(-1).absolutePath);
+ assert.equal(decisionV2.confidence, 0.65);
+ assert.deepEqual(
+ decisionV2.immutable_history_refs,
+ ["records/decisions/bakery-decision.v0001.yaml"],
+ );
+
+ const status = await service.status("bakery");
+ assert.equal(status.decision_versions, 2);
+ assert.equal(status.evidence_count, 2);
+ assert.equal(status.metrics.supporting_evidence_count, 1);
+ assert.equal(status.metrics.contradicting_evidence_count, 1);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+await runSection("planExperiment", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const service = createService(projectRoot);
+ await service.startBusiness(startBusinessInput());
+ await service.addEvidence("bakery", addEvidenceInput());
+
+ const result = await service.planExperiment("bakery", experimentInput());
+
+ assert.equal(result.changed, true);
+ assert.equal(result.state, "approval_pending");
+ assert.equal(result.next_command, "status");
+ assert.equal(result.projection_stale, false);
+ assert.equal(result.record.status, "draft");
+ assert.equal(result.record.validation_outcome, "pending");
+ assert.deepEqual(result.record.approval_references, []);
+
+ const status = await service.status("bakery");
+ assert.equal(status.state, "approval_pending");
+ assert.equal(status.next_command, "status");
+ assert.equal(status.experiment_versions, 1);
+ assert.equal(status.experiment_completeness.complete, true);
+ assert.equal(status.metrics.preregistration_completeness, 1);
+
+ const rebuild = await service.rebuildIndex();
+ assert.deepEqual(rebuild, { recordCount: 5, businessCount: 1, projection_consistent: true });
+
+ const rebuiltStatus = await service.status("bakery");
+ assert.equal(rebuiltStatus.projection_consistent, true);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+await runSection("repeatStart", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const service = createService(projectRoot);
+ await service.startBusiness(startBusinessInput());
+
+ await assert.rejects(
+ () => service.startBusiness(startBusinessInput()),
+ (error) => error.code === "BUSINESS_ALREADY_EXISTS",
+ );
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+await runSection("cancelled", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const service = createService(projectRoot, async () => false);
+
+ const result = await service.startBusiness(startBusinessInput());
+
+ assert.deepEqual(result, { changed: false, reason: "cancelled" });
+ assert.deepEqual(listRecords(projectRoot), []);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+await runSection("blockedGate", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const service = createService(projectRoot);
+ const registry = createSchemaRegistry(ROOT);
+
+ const decision = buildDecisionRecord({
+ business_id: "bakery",
+ owner: "research",
+ evidence_references: ["interview://owner-1"],
+ related_records: [],
+ immutable_history_refs: ["records/decisions/bakery-decision.v0001.yaml"],
+ target_customer: "Independent bakery owners",
+ problem: "Weekly order reconciliation takes too long",
+ hypothesis: "A clearer order view will reduce reconciliation time",
+ confidence: 0.55,
+ evidence: ["interview://owner-1"],
+ counterevidence: [],
+ alternatives: ["keep_manual_process", "use_spreadsheet_template"],
+ expected_outcome: "Weekly reconciliation takes less than one hour",
+ metric: "weekly_reconciliation_minutes",
+ decision: "run_bounded_validation",
+ review_date: "2026-07-24T12:00:00Z",
+ }, clock, { registry });
+
+ await writeRecord({
+ projectRoot,
+ kind: "decision",
+ id: decision.id,
+ version: 1,
+ value: decision,
+ });
+
+ await assert.rejects(
+ () => service.planExperiment("bakery", experimentInput()),
+ (error) => error.code === "DISCOVER_GATE_BLOCKED",
+ );
+
+ const status = await service.status("bakery");
+ assert.deepEqual(status.blocked_commands_by_code, { DISCOVER_GATE_BLOCKED: 1 });
+ assert.equal(status.next_command, "status");
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+await runSection("pendingUnavailable", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const service = createService(projectRoot);
+ await service.startBusiness(startBusinessInput());
+ await service.addEvidence("bakery", addEvidenceInput());
+ await service.planExperiment("bakery", experimentInput());
+
+ await assert.rejects(
+ () => service.planExperiment("bakery", experimentInput()),
+ (error) => error.code === "COMMAND_UNAVAILABLE",
+ );
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
diff --git a/tests/invariants.test.mjs b/tests/invariants.test.mjs
index 71d03db..dfa1168 100644
--- a/tests/invariants.test.mjs
+++ b/tests/invariants.test.mjs
@@ -339,32 +339,37 @@ function transitionIssues(policySet, workflowId, from, to, context = {}) {
return validator.validateTransition(policySet, workflowId, from, to, context);
}
-const COMPLETE_PREREGISTRATION = [
- "problem",
- "supported_decision",
- "hypothesis",
- "confidence",
- "evidence",
- "counterevidence",
- "baseline",
- "comparison_method",
- "metric_formula",
- "metric_population",
- "metric_denominator",
- "metric_data_source",
- "expected_outcome",
- "minimum_meaningful_effect",
- "failure_conditions",
- "stop_conditions",
- "maximum_cash",
- "maximum_labor",
- "maximum_duration",
- "maximum_data",
- "maximum_risk",
- "owner",
- "decision_date",
- "allowed_outcomes",
-];
+async function canonicalRecord(templateId, overrides = {}) {
+ const record = structuredClone((await loadRequiredPolicySet()).templates.get(templateId));
+ return { ...record, ...overrides };
+}
+
+async function activeApproval(action, approverRole = "human_authority", overrides = {}) {
+ return canonicalRecord("approval_record", {
+ status: "active",
+ approver_role: approverRole,
+ actor: "codex-agent",
+ scope: { actions: [action], wildcard: false },
+ decision: "approved",
+ revoked: false,
+ limits: {
+ cash_usd: 0,
+ labor_hours: 24,
+ duration_days: 14,
+ data_classes: ["internal"],
+ risk_level: "high",
+ },
+ issued_at: "2026-07-17T00:00:00Z",
+ effective_at: "2026-07-17T00:00:00Z",
+ expires_at: "2026-07-19T00:00:00Z",
+ review_at: "2026-07-18T00:00:00Z",
+ ...overrides,
+ });
+}
+
+function recordValue(record, fieldPath) {
+ return fieldPath.split(".").reduce((value, field) => value?.[field], record);
+}
test("business and experiment workflows load and pass their schema", async () => {
const result = await validatePolicySet(ROOT);
@@ -387,6 +392,11 @@ test("Discover may transition to Validate", async () => {
test("Build requires passed validation or a bounded Human-approved learning prototype", async () => {
const policySet = await loadRequiredPolicySet("business_lifecycle");
+ const passedValidation = await canonicalRecord("experiment_record", {
+ subtype: "validation",
+ status: "closed",
+ validation_outcome: "passed",
+ });
assert.equal(
transitionIssues(policySet, "business_lifecycle", "validate", "build")
@@ -395,40 +405,81 @@ test("Build requires passed validation or a bounded Human-approved learning prot
);
assert.deepEqual(
transitionIssues(policySet, "business_lifecycle", "validate", "build", {
- recordTypes: [{ id: "experiment_record", subtype: "validation", status: "passed" }],
- approvals: [],
- preregistrationFields: [],
+ records: [passedValidation],
+ now: "2026-07-18T00:00:00Z",
+ actor: "codex-agent",
}),
[],
);
assert.equal(
transitionIssues(policySet, "business_lifecycle", "validate", "build", {
- recordTypes: [],
- approvals: [{ approver: "human_authority", action: "learning_prototype_exception", valid: true }],
- preregistrationFields: [],
+ records: [{
+ record_type: "experiment_record",
+ subtype: "validation",
+ status: "closed",
+ validation_outcome: "passed",
+ }],
+ now: "2026-07-18T00:00:00Z",
+ actor: "codex-agent",
+ }).some((error) => error.code === "BUILD_VALIDATION_REQUIRED"),
+ true,
+ );
+ assert.equal(
+ transitionIssues(policySet, "business_lifecycle", "validate", "build", {
+ records: [{ ...passedValidation, status: "active" }],
+ approvals: [await activeApproval("learning_prototype_exception")],
learningPrototype: {
budgetCapped: true,
nonProduction: true,
expiresAt: "2026-07-16T00:00:00Z",
+ limits: {
+ cash_usd: 0,
+ labor_hours: 1,
+ duration_days: 7,
+ data_classes: ["internal"],
+ risk_level: "low",
+ },
},
- now: "2026-07-17T00:00:00Z",
+ now: "2026-07-18T00:00:00Z",
+ actor: "codex-agent",
}).some((error) => error.code === "BUILD_VALIDATION_REQUIRED"),
true,
);
assert.deepEqual(
transitionIssues(policySet, "business_lifecycle", "validate", "build", {
- recordTypes: [],
- approvals: [{ approver: "human_authority", action: "learning_prototype_exception", valid: true }],
- preregistrationFields: [],
+ records: [],
+ approvals: [await activeApproval("learning_prototype_exception")],
learningPrototype: {
budgetCapped: true,
nonProduction: true,
expiresAt: "2026-07-31T00:00:00Z",
+ limits: {
+ cash_usd: 0,
+ labor_hours: 1,
+ duration_days: 7,
+ data_classes: ["internal"],
+ risk_level: "low",
+ },
},
- now: "2026-07-17T00:00:00Z",
+ now: "2026-07-18T00:00:00Z",
+ actor: "codex-agent",
}),
[],
);
+ assert.equal(
+ transitionIssues(policySet, "business_lifecycle", "validate", "build", {
+ records: [],
+ approvals: [await activeApproval("learning_prototype_exception")],
+ learningPrototype: {
+ budgetCapped: true,
+ nonProduction: true,
+ expiresAt: "2026-07-31T00:00:00Z",
+ },
+ now: "2026-07-18T00:00:00Z",
+ actor: "codex-agent",
+ }).some((error) => error.code === "BUILD_VALIDATION_REQUIRED"),
+ true,
+ );
});
test("Build may transition to Launch only with Human Authority approval", async () => {
@@ -440,12 +491,31 @@ test("Build may transition to Launch only with Human Authority approval", async
);
assert.deepEqual(
transitionIssues(policySet, "business_lifecycle", "build", "launch", {
- recordTypes: [],
- approvals: [{ approver: "human_authority", action: "launch", valid: true }],
- preregistrationFields: [],
+ approvals: [await activeApproval("launch")],
+ now: "2026-07-18T00:00:00Z",
+ actor: "codex-agent",
}),
[],
);
+
+ for (const mutate of [
+ (approval) => { approval.status = "draft"; },
+ (approval) => { approval.status = "closed"; },
+ (approval) => { approval.status = "superseded"; },
+ (approval) => { delete approval.status; },
+ (approval) => { approval.record_type = "decision_record"; },
+ ]) {
+ const approval = await activeApproval("launch");
+ mutate(approval);
+ assert.equal(
+ transitionIssues(policySet, "business_lifecycle", "build", "launch", {
+ approvals: [approval],
+ now: "2026-07-18T00:00:00Z",
+ actor: "codex-agent",
+ }).some((error) => error.code === "LAUNCH_HUMAN_APPROVAL_REQUIRED"),
+ true,
+ );
+ }
});
test("Review supports every approved terminal business outcome", async () => {
@@ -469,29 +539,54 @@ test("Draft cannot transition directly to Running", async () => {
test("experiment Approval requires its approver and complete preregistration", async () => {
const policySet = await loadRequiredPolicySet("experiment_lifecycle");
- const approval = [{ approver: "ceo", action: "experiment", valid: true }];
+ const workflow = policySet.policies.get("experiment_lifecycle");
+ const draft = await canonicalRecord("experiment_record", {
+ status: "draft", approval_references: [],
+ });
+ const approval = await activeApproval("experiment", "ceo");
+ assert.deepEqual(
+ workflow.preregistration_required_fields
+ .filter((field) => recordValue(draft, field) === undefined),
+ [],
+ );
+
+ assert.equal(
+ transitionIssues(policySet, "experiment_lifecycle", "approval", "running", {
+ experimentRecord: { ...draft, problem: "" },
+ approvals: [approval],
+ now: "2026-07-18T00:00:00Z",
+ actor: "codex-agent",
+ }).some((error) => error.code === "EXPERIMENT_PREREGISTRATION_INCOMPLETE"),
+ true,
+ );
assert.equal(
transitionIssues(policySet, "experiment_lifecycle", "approval", "running", {
- recordTypes: [],
- approvals: approval,
- preregistrationFields: COMPLETE_PREREGISTRATION.slice(1),
+ experimentRecord: draft,
+ approvals: [await activeApproval("experiment", "ceo", {
+ expires_at: "2026-07-18T00:00:00Z",
+ review_at: "2026-07-17T12:00:00Z",
+ })],
+ now: "2026-07-18T00:00:00Z",
+ actor: "codex-agent",
}).some((error) => error.code === "EXPERIMENT_PREREGISTRATION_INCOMPLETE"),
true,
);
assert.equal(
transitionIssues(policySet, "experiment_lifecycle", "approval", "running", {
- recordTypes: [],
- approvals: [],
- preregistrationFields: COMPLETE_PREREGISTRATION,
+ experimentRecord: draft,
+ approvals: [approval],
+ now: "2026-07-18T00:00:00Z",
+ actor: "other-agent",
}).some((error) => error.code === "EXPERIMENT_PREREGISTRATION_INCOMPLETE"),
true,
);
assert.deepEqual(
transitionIssues(policySet, "experiment_lifecycle", "approval", "running", {
- recordTypes: [],
- approvals: approval,
- preregistrationFields: COMPLETE_PREREGISTRATION,
+ experimentRecord: draft,
+ approvals: [approval],
+ now: "2026-07-18T00:00:00Z",
+ actor: "codex-agent",
}),
[],
);
@@ -499,17 +594,28 @@ test("experiment Approval requires its approver and complete preregistration", a
test("experiment closure requires actuals, reflection, decision, and Experience linkage", async () => {
const policySet = await loadRequiredPolicySet("experiment_lifecycle");
- const required = policySet.policies.get("experiment_lifecycle").closure_required_fields;
+ const workflow = policySet.policies.get("experiment_lifecycle");
+ const closed = await canonicalRecord("experiment_record", {
+ subtype: "validation",
+ status: "closed",
+ validation_outcome: "passed",
+ });
+
+ assert.deepEqual(
+ workflow.closure_required_fields
+ .filter((field) => recordValue(closed, field) === undefined),
+ [],
+ );
assert.equal(
transitionIssues(policySet, "experiment_lifecycle", "decision", "closed", {
- closureFields: required.slice(1),
+ experimentRecord: { ...closed, reflection: "" },
}).some((error) => error.code === "WORKFLOW_TRANSITION_INVALID"),
true,
);
assert.deepEqual(
transitionIssues(policySet, "experiment_lifecycle", "decision", "closed", {
- closureFields: required,
+ experimentRecord: closed,
}),
[],
);
@@ -544,7 +650,7 @@ test("forbidden-transition fixture is rejected", async () => {
"experiment_lifecycle",
fixture.from,
fixture.to,
- { recordTypes: [], approvals: [], preregistrationFields: [] },
+ {},
)
.filter((error) => error.code === "WORKFLOW_TRANSITION_INVALID")
.map(({ code }) => ({ code })),
diff --git a/tests/no-network.test.mjs b/tests/no-network.test.mjs
new file mode 100644
index 0000000..eb898e8
--- /dev/null
+++ b/tests/no-network.test.mjs
@@ -0,0 +1,326 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import http from "node:http";
+import http2 from "node:http2";
+import https from "node:https";
+import net from "node:net";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { workspacePaths } from "../src/storage/workspace.mjs";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+const FORBIDDEN_IMPORTS = [
+ "http",
+ "node:http",
+ "https",
+ "node:https",
+ "http2",
+ "node:http2",
+ "net",
+ "node:net",
+ "tls",
+ "node:tls",
+ "dgram",
+ "node:dgram",
+ "dns",
+ "node:dns",
+ "undici",
+ "axios",
+ "openai",
+];
+
+const CLOCK = () => new Date("2026-07-17T12:00:00Z");
+
+function makeProjectRoot() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "genesis-no-network-"));
+}
+
+function cleanupProjectRoot(projectRoot) {
+ fs.rmSync(projectRoot, { recursive: true, force: true });
+}
+
+function createBuffer() {
+ let text = "";
+ return {
+ write(chunk) {
+ text += chunk;
+ return true;
+ },
+ toString() {
+ return text;
+ },
+ };
+}
+
+function createScriptedPrompter(answers, output) {
+ let index = 0;
+
+ function nextAnswer(fallback = "") {
+ const answer = index < answers.length ? answers[index] : fallback;
+ index += 1;
+ return answer;
+ }
+
+ return {
+ async ask(question) {
+ output.write(question);
+ const answer = nextAnswer("");
+ output.write(`${answer}\n`);
+ return answer;
+ },
+ async choose(question, choices) {
+ output.write(`${question}\n`);
+ for (const [choiceIndex, choice] of choices.entries()) {
+ output.write(` ${choiceIndex + 1}. ${typeof choice === "string" ? choice : choice.label}\n`);
+ }
+ output.write("> ");
+ const answer = nextAnswer("");
+ output.write(`${answer}\n`);
+ if (!answer) {
+ return typeof choices[0] === "string" ? choices[0] : choices[0]?.value;
+ }
+
+ const numeric = Number.parseInt(answer, 10);
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= choices.length) {
+ const selected = choices[numeric - 1];
+ return typeof selected === "string" ? selected : selected.value;
+ }
+
+ const selected = choices.find((choice) => (
+ (typeof choice === "string" ? choice : choice.label) === answer
+ || (typeof choice === "string" ? choice : choice.value) === answer
+ ));
+ return typeof selected === "string" ? selected : selected?.value ?? (typeof choices[0] === "string" ? choices[0] : choices[0]?.value);
+ },
+ async confirm(question) {
+ output.write(question);
+ const answer = nextAnswer("n");
+ output.write(`${answer}\n`);
+ return ["y", "yes", "true", "1"].includes(String(answer).trim().toLowerCase());
+ },
+ async close() {},
+ };
+}
+
+function runOutput(text) {
+ return text.replaceAll("\r\n", "\n");
+}
+
+function collectSourceFiles(directory, results = []) {
+ if (!fs.existsSync(directory)) {
+ return results;
+ }
+
+ for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
+ const absolutePath = path.join(directory, entry.name);
+ if (entry.isDirectory()) {
+ collectSourceFiles(absolutePath, results);
+ continue;
+ }
+
+ if (entry.isFile() && /\.(mjs|js|cjs|json)$/u.test(entry.name)) {
+ results.push(absolutePath);
+ }
+ }
+
+ return results;
+}
+
+function importedSpecifiers(source) {
+ const specifiers = new Set();
+ const patterns = [
+ /\b(?:import|export)\s+(?:[^;"']*?\s+from\s*)?["']([^"']+)["']/gu,
+ /\bimport\s*\(\s*["']([^"']+)["']\s*\)/gu,
+ /\brequire\s*\(\s*["']([^"']+)["']\s*\)/gu,
+ ];
+ for (const pattern of patterns) {
+ for (const match of source.matchAll(pattern)) {
+ specifiers.add(match[1]);
+ }
+ }
+ return specifiers;
+}
+
+function installNetworkGuards() {
+ const blocked = () => {
+ throw new Error("NETWORK_USED");
+ };
+ const targets = [
+ [http, "request"],
+ [http, "get"],
+ [https, "request"],
+ [https, "get"],
+ [http2, "connect"],
+ [net, "connect"],
+ [net, "createConnection"],
+ [net.Socket.prototype, "connect"],
+ ];
+ const originals = targets.map(([target, key]) => [target, key, target[key]]);
+ for (const [target, key] of targets) {
+ target[key] = blocked;
+ }
+ return () => {
+ for (const [target, key, value] of originals) {
+ target[key] = value;
+ }
+ };
+}
+
+test("source tree has no network-capable imports or fetch calls", { concurrency: false }, async () => {
+ const sourceFiles = [
+ ...collectSourceFiles(path.join(ROOT, "bin")),
+ ...collectSourceFiles(path.join(ROOT, "src")),
+ ];
+
+ const violations = [];
+ for (const filePath of sourceFiles) {
+ const source = fs.readFileSync(filePath, "utf8");
+ const specifiers = importedSpecifiers(source);
+ for (const specifier of FORBIDDEN_IMPORTS) {
+ if (specifiers.has(specifier)) {
+ violations.push(`${path.relative(ROOT, filePath)} imports ${specifier}`);
+ }
+ }
+
+ if (/\bfetch\s*\(/u.test(source)) {
+ violations.push(`${path.relative(ROOT, filePath)} references fetch(`);
+ }
+ }
+
+ assert.deepEqual(violations, []);
+});
+
+test("CLI flow completes without using fetch", { concurrency: false }, async () => {
+ const projectRoot = makeProjectRoot();
+ const output = createBuffer();
+ const originalFetch = globalThis.fetch;
+ const restoreNetwork = installNetworkGuards();
+ globalThis.fetch = async () => {
+ throw new Error("NETWORK_USED");
+ };
+
+ try {
+ const { runCli } = await import("../src/cli/run-cli.mjs");
+
+ const scriptedPrompter = createScriptedPrompter([
+ "bakery",
+ "Independent bakery owners",
+ "Weekly order reconciliation takes too long",
+ "A clearer order view will reduce reconciliation time",
+ "0.55",
+ "interview://owner-1",
+ "Owner spends two hours on reconciliation every week",
+ "1",
+ "Interview note",
+ "1",
+ "Two owners object to learning curve",
+ "keep_manual_process,use_spreadsheet_template",
+ "Weekly reconciliation takes less than one hour",
+ "weekly_reconciliation_minutes",
+ "run_bounded_validation",
+ "research",
+ "2026-07-24T12:00:00Z",
+ "y",
+ "interview://owner-2",
+ "A second owner also wants the same flow",
+ "contradict",
+ "Interview note",
+ "internal",
+ "y",
+ "",
+ "research",
+ "Owners currently take two hours each week",
+ "Compare observed time with the two-hour baseline",
+ "sum_reconciliation_minutes_divided_by_sessions",
+ "qualified_bakery_owners",
+ "completed_reconciliation_sessions",
+ "observed_session_log",
+ "Median reconciliation time is below one hour",
+ "median_time_reduction_at_least_60_minutes",
+ "median_time_is_not_reduced",
+ "participant_harm,privacy_incident",
+ "0",
+ "8",
+ "7",
+ "internal",
+ "1",
+ "2026-07-17T12:00:00Z",
+ "scale,pivot,learning_lab,archive,kill",
+ "y",
+ ], output);
+
+ const startExit = await runCli(["start-business"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(startExit, 0);
+
+ const addExit = await runCli(["add-evidence", "bakery"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(addExit, 0);
+
+ const statusExit = await runCli(["status", "bakery"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(statusExit, 0);
+
+ const planExit = await runCli(["plan-experiment", "bakery"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(planExit, 0);
+
+ fs.rmSync(workspacePaths(projectRoot).db, { force: true });
+
+ const rebuildExit = await runCli(["rebuild-index"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(rebuildExit, 0);
+
+ const statusAfterRebuildExit = await runCli(["status", "bakery"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: scriptedPrompter,
+ output,
+ errorOutput: output,
+ });
+ assert.equal(statusAfterRebuildExit, 0);
+
+ const text = runOutput(output.toString());
+ assert.equal(text.includes("NETWORK_USED"), false);
+ assert.equal(text.includes("Projection consistent: yes"), true);
+ assert.equal(text.includes("Records rebuilt: 5"), true);
+ assert.equal(text.includes("Businesses rebuilt: 1"), true);
+ } finally {
+ globalThis.fetch = originalFetch;
+ restoreNetwork();
+ cleanupProjectRoot(projectRoot);
+ }
+});
diff --git a/tests/projection.test.mjs b/tests/projection.test.mjs
new file mode 100644
index 0000000..cb695de
--- /dev/null
+++ b/tests/projection.test.mjs
@@ -0,0 +1,290 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { createSchemaRegistry } from "../src/core/schema-registry.mjs";
+import { buildDecisionRecord, buildEvidenceEntry, buildExperimentRecord, versionDecisionRecord } from "../src/core/record-builders.mjs";
+import { openProjection, projectRecord, readOpportunity, projectionConsistency, rebuildProjection, recordBlockedCommand } from "../src/storage/projection.mjs";
+import { ensureWorkspace, workspacePaths } from "../src/storage/workspace.mjs";
+import { listRecords, writeRecord } from "../src/storage/yaml-record-store.mjs";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+
+const clock1 = () => new Date("2026-07-17T08:00:00Z");
+const clock2 = () => new Date("2026-07-17T09:00:00Z");
+
+const decisionInput = {
+ business_id: "bakery",
+ owner: "research",
+ evidence_references: ["evidence://bakery/interview-1"],
+ related_records: [],
+ immutable_history_refs: ["records/decisions/bakery-decision.v0001.yaml"],
+ target_customer: "Independent bakery owners",
+ problem: "Weekly order reconciliation takes too long",
+ hypothesis: "A clearer order view will reduce reconciliation time",
+ confidence: 0.55,
+ evidence: ["evidence://bakery/interview-1"],
+ counterevidence: [],
+ alternatives: ["keep_manual_process", "use_spreadsheet_template"],
+ expected_outcome: "Weekly reconciliation takes less than one hour",
+ metric: "weekly_reconciliation_minutes",
+ decision: "run_bounded_validation",
+ review_date: "2026-07-24T08:00:00Z",
+};
+
+const experimentInput = {
+ business_id: "bakery",
+ owner: "research",
+ evidence_references: ["evidence://bakery/interview-1"],
+ related_records: ["bakery-decision"],
+ immutable_history_refs: ["records/experiments/bakery-experiment.v0001.yaml"],
+ problem: "Determine whether the proposed order view reduces reconciliation time",
+ supported_decision: "bakery-decision",
+ hypothesis: "Bakery owners complete reconciliation in less than one hour",
+ confidence: 0.55,
+ evidence: ["evidence://bakery/interview-1"],
+ counterevidence: [],
+ baseline: "Owners currently take two hours each week",
+ comparison_method: "Compare observed time with the two-hour baseline",
+ metric: {
+ formula: "sum_reconciliation_minutes_divided_by_sessions",
+ population: "qualified_bakery_owners",
+ denominator: "completed_reconciliation_sessions",
+ data_source: "observed_session_log",
+ },
+ expected_outcome: "Median reconciliation time is below one hour",
+ minimum_meaningful_effect: "median_time_reduction_at_least_60_minutes",
+ failure_conditions: ["median_time_is_not_reduced"],
+ stop_conditions: ["participant_harm", "privacy_incident"],
+ limits: {
+ cash_usd: 0,
+ labor_hours: 8,
+ duration_days: 7,
+ data_classes: ["internal"],
+ risk_level: "low",
+ },
+ decision_date: "2026-07-24T08:00:00Z",
+};
+
+function makeProjectRoot() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "genesis-projection-"));
+}
+
+function cleanupProjectRoot(projectRoot) {
+ fs.rmSync(projectRoot, { recursive: true, force: true });
+}
+
+function descriptorFor(kind, written, record, version) {
+ return {
+ kind,
+ id: record.id,
+ version,
+ relativePath: written.relativePath,
+ };
+}
+
+function snapshotProjection(db) {
+ return {
+ record_versions: db.prepare(`
+ SELECT record_type, record_id, version, relative_path, updated_at
+ FROM record_versions
+ ORDER BY record_type, record_id, version
+ `).all(),
+ opportunities: db.prepare(`
+ SELECT business_id, decision_id, state, created_at, updated_at, latest_decision_path,
+ latest_experiment_path, support_count, contradict_count, confidence, discover_started_at,
+ validation_planned_at, projection_consistent
+ FROM opportunities
+ ORDER BY business_id
+ `).all(),
+ blocked_commands: db.prepare(`
+ SELECT id, business_id, command, code, occurred_at
+ FROM blocked_commands
+ ORDER BY id
+ `).all(),
+ };
+}
+
+test("projection captures latest paths, counts, and rebuilds deterministically", { concurrency: false }, async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const registry = createSchemaRegistry(ROOT);
+ ensureWorkspace(projectRoot);
+
+ const decisionV1 = buildDecisionRecord(decisionInput, clock1);
+ const decisionV2 = versionDecisionRecord(decisionV1, { confidence: 0.65 }, decisionV1.immutable_history_refs[0], clock2);
+ const evidence = buildEvidenceEntry({
+ id: "bakery-evidence-001",
+ business_id: "bakery",
+ source_reference: "interview://owner-1",
+ summary: "Owner spends two hours on reconciliation every week",
+ stance: "support",
+ provenance: "Interview note",
+ privacy_classification: "internal",
+ }, clock1);
+ const experiment = buildExperimentRecord(experimentInput, clock2);
+
+ const decisionV1Path = await writeRecord({
+ projectRoot,
+ kind: "decision",
+ id: decisionV1.id,
+ version: 1,
+ value: decisionV1,
+ });
+ const decisionV2Path = await writeRecord({
+ projectRoot,
+ kind: "decision",
+ id: decisionV2.id,
+ version: 2,
+ value: decisionV2,
+ });
+ const evidencePath = await writeRecord({
+ projectRoot,
+ kind: "evidence",
+ id: evidence.id,
+ version: 1,
+ value: evidence,
+ });
+ const experimentPath = await writeRecord({
+ projectRoot,
+ kind: "experiment",
+ id: experiment.id,
+ version: 1,
+ value: experiment,
+ });
+
+ fs.writeFileSync(
+ path.join(workspacePaths(projectRoot).records, "decisions", "ignored.tmp.yaml"),
+ "id: ignored\nrecord_type: decision_record\n",
+ "utf8",
+ );
+
+ const dbPath = workspacePaths(projectRoot).db;
+ const db = openProjection(dbPath);
+ projectRecord(db, descriptorFor("decision", decisionV1Path, decisionV1, 1), {
+ ...decisionV1,
+ relativePath: decisionV1Path.relativePath,
+ version: 1,
+ });
+ projectRecord(db, descriptorFor("decision", decisionV2Path, decisionV2, 2), {
+ ...decisionV2,
+ relativePath: decisionV2Path.relativePath,
+ version: 2,
+ });
+ projectRecord(db, descriptorFor("evidence", evidencePath, evidence, 1), {
+ ...evidence,
+ relativePath: evidencePath.relativePath,
+ version: 1,
+ });
+ projectRecord(db, descriptorFor("evidence", evidencePath, evidence, 1), {
+ ...evidence,
+ relativePath: evidencePath.relativePath,
+ version: 1,
+ });
+ projectRecord(db, descriptorFor("experiment", experimentPath, experiment, 1), {
+ ...experiment,
+ relativePath: experimentPath.relativePath,
+ version: 1,
+ });
+
+ const opportunity = readOpportunity(db, "bakery");
+ assert.equal(opportunity.latest_decision_path, decisionV2Path.relativePath);
+ assert.equal(opportunity.latest_experiment_path, experimentPath.relativePath);
+ assert.equal(opportunity.state, "approval_pending");
+ assert.equal(opportunity.support_count, 1);
+ assert.equal(opportunity.contradict_count, 0);
+ assert.equal(opportunity.confidence, 0.65);
+ assert.equal(opportunity.created_at, decisionV1.created_at);
+ assert.equal(opportunity.updated_at, decisionV2.updated_at);
+ assert.equal(opportunity.discover_started_at, decisionV1.created_at);
+ assert.equal(opportunity.validation_planned_at, experiment.decision_date);
+
+ const consistency = projectionConsistency(db, listRecords(projectRoot));
+ assert.deepEqual(consistency, {
+ consistent: true,
+ yamlCount: 4,
+ projectedCount: 4,
+ });
+ assert.equal(projectionConsistency(db, listRecords(projectRoot).map((descriptor, index) => (
+ index === 0 ? { ...descriptor, id: "wrong-id" } : descriptor
+ ))).consistent, false);
+
+ const originalSnapshot = {
+ record_versions: snapshotProjection(db).record_versions,
+ opportunities: snapshotProjection(db).opportunities,
+ };
+
+ recordBlockedCommand(db, {
+ business_id: "bakery",
+ command: "start-business",
+ code: "DISCOVER_GATE_BLOCKED",
+ occurred_at: "2026-07-17T10:00:00Z",
+ });
+ assert.deepEqual(snapshotProjection(db).blocked_commands, [{
+ id: 1,
+ business_id: "bakery",
+ command: "start-business",
+ code: "DISCOVER_GATE_BLOCKED",
+ occurred_at: "2026-07-17T10:00:00Z",
+ }]);
+
+ db.close();
+
+ fs.rmSync(dbPath, { force: true });
+ const rebuilt = await rebuildProjection({ projectRoot, registry });
+ assert.deepEqual(rebuilt, { recordCount: 4, businessCount: 1 });
+
+ const rebuiltDb = openProjection(dbPath);
+ const rebuiltSnapshot = snapshotProjection(rebuiltDb);
+ rebuiltDb.close();
+
+ assert.deepEqual(rebuiltSnapshot.record_versions, originalSnapshot.record_versions);
+ assert.deepEqual(rebuiltSnapshot.opportunities, originalSnapshot.opportunities);
+ assert.deepEqual(rebuiltSnapshot.blocked_commands, []);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("rebuildProjection preserves the prior database when YAML validation fails", { concurrency: false }, async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const registry = createSchemaRegistry(ROOT);
+ ensureWorkspace(projectRoot);
+
+ const decision = buildDecisionRecord(decisionInput, clock1);
+ const decisionPath = await writeRecord({
+ projectRoot,
+ kind: "decision",
+ id: decision.id,
+ version: 1,
+ value: decision,
+ });
+ const dbPath = workspacePaths(projectRoot).db;
+ const db = openProjection(dbPath);
+ projectRecord(db, descriptorFor("decision", decisionPath, decision, 1), {
+ ...decision,
+ relativePath: decisionPath.relativePath,
+ version: 1,
+ });
+ db.close();
+
+ const before = fs.readFileSync(dbPath);
+ fs.writeFileSync(
+ path.join(workspacePaths(projectRoot).records, "experiments", "broken.v0001.yaml"),
+ "id: broken\nrecord_type: experiment_record\nlimits: [",
+ "utf8",
+ );
+
+ assert.throws(
+ () => rebuildProjection({ projectRoot, registry }),
+ (error) => error instanceof Error && error.code === "RECORD_SCHEMA_INVALID",
+ );
+
+ assert.deepEqual(fs.readFileSync(dbPath), before);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
diff --git a/tests/record-builders.test.mjs b/tests/record-builders.test.mjs
new file mode 100644
index 0000000..0cd0e68
--- /dev/null
+++ b/tests/record-builders.test.mjs
@@ -0,0 +1,206 @@
+import assert from "node:assert/strict";
+import path from "node:path";
+import { fileURLToPath } from "node:url";
+import test from "node:test";
+
+import { GenesisError } from "../src/core/errors.mjs";
+import {
+ buildDecisionRecord,
+ buildEvidenceEntry,
+ buildExperimentRecord,
+ versionDecisionRecord,
+} from "../src/core/record-builders.mjs";
+import { createSchemaRegistry } from "../src/core/schema-registry.mjs";
+
+const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
+const clock = () => new Date("2026-07-18T00:00:00Z");
+
+const validDecisionInput = {
+ business_id: "bakery",
+ owner: "research",
+ evidence_references: ["evidence://bakery/owner-1"],
+ related_records: [],
+ immutable_history_refs: ["records/decisions/bakery-decision.v0001.yaml"],
+ target_customer: "Independent bakery owners",
+ problem: "Order reconciliation takes two hours each week",
+ hypothesis: "A consolidated order view will reduce reconciliation time",
+ confidence: 0.55,
+ evidence: ["evidence://bakery/owner-1"],
+ counterevidence: [],
+ alternatives: ["keep_manual_process", "use_spreadsheet_template"],
+ expected_outcome: "Weekly reconciliation takes less than one hour",
+ metric: "weekly_reconciliation_minutes",
+ decision: "plan_bounded_validation",
+ review_date: "2026-07-25T00:00:00Z",
+};
+
+const validExperimentInput = {
+ business_id: "bakery",
+ owner: "research",
+ evidence_references: ["evidence://bakery/owner-1"],
+ related_records: ["bakery-decision"],
+ immutable_history_refs: ["records/experiments/bakery-experiment.v0001.yaml"],
+ problem: "Determine whether the order view reduces reconciliation time",
+ supported_decision: "bakery-decision",
+ hypothesis: "Bakery owners complete reconciliation in less than one hour",
+ confidence: 0.55,
+ evidence: ["evidence://bakery/owner-1"],
+ counterevidence: [],
+ baseline: "Owners currently take two hours each week",
+ comparison_method: "Compare observed time with the two-hour baseline",
+ metric: {
+ formula: "sum_reconciliation_minutes_divided_by_sessions",
+ population: "qualified_bakery_owners",
+ denominator: "completed_reconciliation_sessions",
+ data_source: "observed_session_log",
+ },
+ expected_outcome: "Median reconciliation time is below one hour",
+ minimum_meaningful_effect: "median_time_reduction_at_least_60_minutes",
+ failure_conditions: ["median_time_is_not_reduced"],
+ stop_conditions: ["participant_harm", "privacy_incident"],
+ limits: {
+ cash_usd: 0,
+ labor_hours: 8,
+ duration_days: 7,
+ data_classes: ["internal"],
+ risk_level: "low",
+ },
+ decision_date: "2026-07-25T00:00:00Z",
+};
+
+test("builders produce schema-valid evidence and canonical records", async () => {
+ const registry = await createSchemaRegistry(ROOT);
+ const evidence = buildEvidenceEntry({
+ id: "bakery-ev-001",
+ business_id: "bakery",
+ source_reference: "interview://owner-1",
+ summary: "Owner loses two hours weekly reconciling orders",
+ stance: "support",
+ provenance: "User-entered interview note",
+ privacy_classification: "internal",
+ }, clock);
+
+ assert.equal(registry.validateEvidence(evidence), evidence);
+ assert.equal(evidence.collected_at, "2026-07-18T00:00:00.000Z");
+
+ const decision = buildDecisionRecord(validDecisionInput, clock);
+ assert.equal(registry.validateRecord("decision_record", decision), decision);
+ assert.equal(decision.id, "bakery-decision");
+ assert.equal(decision.created_at, "2026-07-18T00:00:00.000Z");
+ assert.equal(decision.updated_at, decision.created_at);
+ assert.equal(decision.privacy_classification, "internal");
+
+ const v2 = versionDecisionRecord(
+ decision,
+ { confidence: 0.65 },
+ "records/decisions/bakery-decision.v0001.yaml",
+ clock,
+ );
+ assert.deepEqual(v2.immutable_history_refs, [
+ "records/decisions/bakery-decision.v0001.yaml",
+ ]);
+ assert.equal(v2.confidence, 0.65);
+ assert.equal(registry.validateRecord("decision_record", v2), v2);
+
+ const experiment = buildExperimentRecord(validExperimentInput, clock);
+ assert.equal(registry.validateRecord("experiment_record", experiment), experiment);
+ assert.equal(experiment.id, "bakery-experiment");
+ assert.deepEqual(experiment.approval_references, []);
+ assert.equal(experiment.validation_outcome, "pending");
+ for (const field of [
+ "actual_cost",
+ "results",
+ "reflection",
+ "outcome",
+ "experience_reference",
+ "confidence_update",
+ "decision_outcome",
+ ]) {
+ assert.equal(Object.hasOwn(experiment, field), false, field);
+ }
+});
+
+test("builders reject restricted evidence and schema-invalid records", () => {
+ const registry = createSchemaRegistry(ROOT);
+ assert.throws(
+ () => buildEvidenceEntry({
+ id: "bakery-ev-002",
+ business_id: "bakery",
+ source_reference: "file://restricted-note",
+ summary: "Sensitive evidence",
+ stance: "support",
+ provenance: "User-entered note",
+ privacy_classification: "restricted",
+ }, clock),
+ (error) => error instanceof GenesisError
+ && error.code === "SENSITIVE_DATA_FORBIDDEN"
+ && error.path === "/privacy_classification",
+ );
+
+ assert.throws(
+ () => buildDecisionRecord({ ...validDecisionInput, confidence: 1.01 }, clock),
+ (error) => error instanceof GenesisError
+ && error.code === "RECORD_SCHEMA_INVALID"
+ && error.path === "/confidence"
+ && error.correction.includes("must be <= 1")
+ && error.escalation === "builder",
+ );
+
+ assert.throws(
+ () => buildExperimentRecord({
+ ...validExperimentInput,
+ limits: {
+ ...validExperimentInput.limits,
+ data_classes: ["internal", "restricted"],
+ },
+ }, clock),
+ (error) => error instanceof GenesisError
+ && error.code === "SENSITIVE_DATA_FORBIDDEN"
+ && error.path === "/limits/data_classes"
+ && error.correction.includes("restricted"),
+ );
+
+ const experiment = buildExperimentRecord(validExperimentInput, clock);
+ assert.throws(
+ () => registry.validateRecord("experiment_record", {
+ ...experiment,
+ limits: { ...experiment.limits, data_classes: ["restricted"] },
+ }),
+ (error) => error.code === "RECORD_SCHEMA_INVALID" && error.path === "/limits/data_classes/0",
+ );
+});
+
+test("builders can validate against an injected registry", () => {
+ let evidenceCalls = 0;
+ let recordCalls = 0;
+ const injectedRegistry = {
+ validateEvidence(value) {
+ evidenceCalls += 1;
+ return value;
+ },
+ validateRecord(recordType, value) {
+ recordCalls += 1;
+ return { recordType, value };
+ },
+ };
+
+ const evidence = buildEvidenceEntry({
+ id: "bakery-ev-003",
+ business_id: "bakery",
+ source_reference: "interview://owner-2",
+ summary: "Injected registry evidence",
+ stance: "support",
+ provenance: "User-entered note",
+ privacy_classification: "internal",
+ }, clock, { registry: injectedRegistry });
+
+ assert.equal(evidenceCalls, 1);
+ assert.equal(evidence.privacy_classification, "internal");
+
+ const decision = buildDecisionRecord(validDecisionInput, clock, { registry: injectedRegistry });
+ const experiment = buildExperimentRecord(validExperimentInput, clock, { registry: injectedRegistry });
+
+ assert.equal(recordCalls, 2);
+ assert.equal(decision.recordType, "decision_record");
+ assert.equal(experiment.recordType, "experiment_record");
+});
diff --git a/tests/records.test.mjs b/tests/records.test.mjs
index 4815930..53dda69 100644
--- a/tests/records.test.mjs
+++ b/tests/records.test.mjs
@@ -133,6 +133,112 @@ test("approval scope and actor match exactly", () => {
);
});
+test("only active canonical approval records validate", () => {
+ const validContext = {
+ now: "2026-07-18T00:00:00Z",
+ action: "production_deployment",
+ actor: "builder-agent",
+ };
+
+ for (const status of ["draft", "closed", "superseded"]) {
+ const record = loadTemplate("approval_record");
+ record.status = status;
+ assert.equal(
+ validator.validateApproval(record, validContext).some((issue) => (
+ issue.code === "APPROVAL_STATUS_INVALID" && issue.path === "/status"
+ )),
+ true,
+ status,
+ );
+ }
+
+ const statusless = loadTemplate("approval_record");
+ delete statusless.status;
+ assert.equal(
+ validator.validateApproval(statusless, validContext).some((issue) => (
+ issue.code === "APPROVAL_STATUS_INVALID" && issue.path === "/status"
+ )),
+ true,
+ );
+
+ const noncanonical = loadTemplate("approval_record");
+ noncanonical.record_type = "decision_record";
+ assert.equal(
+ validator.validateApproval(noncanonical, validContext).some((issue) => (
+ issue.code === "APPROVAL_RECORD_INVALID" && issue.path === "/record_type"
+ )),
+ true,
+ );
+});
+
+test("approval limits cannot exceed the canonical envelope", () => {
+ assert.equal(typeof validator.validateApproval, "function");
+ const record = loadTemplate("approval_record");
+ const validContext = {
+ now: "2026-07-18T00:00:00Z",
+ action: "production_deployment",
+ actor: "builder-agent",
+ };
+ const mismatches = [
+ { cash_usd: record.limits.cash_usd + 1 },
+ { labor_hours: record.limits.labor_hours + 1 },
+ { duration_days: record.limits.duration_days + 1 },
+ { data_classes: [...record.limits.data_classes, "public"] },
+ { data_classes: ["unknown"] },
+ { risk_level: "critical" },
+ ];
+
+ for (const limits of mismatches) {
+ assert.equal(
+ validator.validateApproval(record, { ...validContext, limits })
+ .some((issue) => issue.code === "APPROVAL_LIMIT_MISMATCH"),
+ true,
+ JSON.stringify(limits),
+ );
+ }
+
+ const unknownApprovedClass = loadTemplate("approval_record");
+ unknownApprovedClass.limits.data_classes = ["unknown"];
+ assert.equal(
+ validator.validateApproval(unknownApprovedClass, validContext)
+ .some((issue) => issue.code === "APPROVAL_LIMIT_MISMATCH"),
+ true,
+ );
+});
+
+test("approval and experiment limit data classes use the privacy enum", () => {
+ for (const recordId of ["approval_record", "experiment_record"]) {
+ const record = loadTemplate(recordId);
+ record.limits.data_classes = ["unknown"];
+ const validate = compileSchema(recordId);
+ assert.equal(validate(record), false, recordId);
+ }
+});
+
+test("draft experiment requires preregistration but not closure fields", async () => {
+ const draft = loadTemplate("experiment_record");
+ draft.status = "draft";
+ draft.approval_references = [];
+ for (const field of ["actual_cost", "results", "reflection", "outcome", "experience_reference", "confidence_update", "decision_outcome"]) {
+ delete draft[field];
+ }
+ assert.equal(compileSchema("experiment_record")(draft), true);
+});
+
+test("closed validation experiment requires closure and validation outcome", async () => {
+ const closed = loadTemplate("experiment_record");
+ delete closed.validation_outcome;
+ assert.equal(compileSchema("experiment_record")(closed), false);
+ closed.validation_outcome = "passed";
+ assert.equal(compileSchema("experiment_record")(closed), true);
+});
+
+test("decision record carries the target customer", async () => {
+ const decision = loadTemplate("decision_record");
+ delete decision.target_customer;
+ assert.equal(compileSchema("decision_record")(decision), false);
+});
+
test("record references use declared record identifiers", async () => {
const policySet = await validator.loadPolicySet(ROOT);
const record = policySet.templates.get("decision_record");
diff --git a/tests/recovery.test.mjs b/tests/recovery.test.mjs
new file mode 100644
index 0000000..856791e
--- /dev/null
+++ b/tests/recovery.test.mjs
@@ -0,0 +1,249 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { createSchemaRegistry } from "../src/core/schema-registry.mjs";
+import { createGenesisService } from "../src/application/genesis-service.mjs";
+import { listRecords, readRecord } from "../src/storage/yaml-record-store.mjs";
+import { workspacePaths } from "../src/storage/workspace.mjs";
+import { runCli } from "../src/cli/run-cli.mjs";
+
+const ROOT = path.resolve(import.meta.dirname, "..");
+const CLOCK = () => new Date("2026-07-17T12:00:00Z");
+
+function makeProjectRoot() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "genesis-recovery-"));
+}
+
+function cleanupProjectRoot(projectRoot) {
+ fs.rmSync(projectRoot, { recursive: true, force: true });
+}
+
+function createBuffer() {
+ let text = "";
+ return {
+ write(chunk) {
+ text += chunk;
+ return true;
+ },
+ toString() {
+ return text;
+ },
+ };
+}
+
+function createScriptedPrompter(answers, output) {
+ let index = 0;
+
+ function nextAnswer(fallback = "") {
+ const answer = index < answers.length ? answers[index] : fallback;
+ index += 1;
+ return answer;
+ }
+
+ return {
+ async ask(question) {
+ output.write(question);
+ const answer = nextAnswer("");
+ output.write(`${answer}\n`);
+ return answer;
+ },
+ async choose(question, choices) {
+ output.write(`${question}\n`);
+ for (const [choiceIndex, choice] of choices.entries()) {
+ output.write(` ${choiceIndex + 1}. ${typeof choice === "string" ? choice : choice.label}\n`);
+ }
+ output.write("> ");
+ const answer = nextAnswer("");
+ output.write(`${answer}\n`);
+ if (!answer) {
+ return typeof choices[0] === "string" ? choices[0] : choices[0]?.value;
+ }
+
+ const numeric = Number.parseInt(answer, 10);
+ if (Number.isInteger(numeric) && numeric >= 1 && numeric <= choices.length) {
+ const selected = choices[numeric - 1];
+ return typeof selected === "string" ? selected : selected.value;
+ }
+
+ const selected = choices.find((choice) => (
+ (typeof choice === "string" ? choice : choice.label) === answer
+ || (typeof choice === "string" ? choice : choice.value) === answer
+ ));
+ return typeof selected === "string" ? selected : selected?.value ?? (typeof choices[0] === "string" ? choices[0] : choices[0]?.value);
+ },
+ async confirm(question) {
+ output.write(question);
+ const answer = nextAnswer("n");
+ output.write(`${answer}\n`);
+ return ["y", "yes", "true", "1"].includes(String(answer).trim().toLowerCase());
+ },
+ async close() {},
+ };
+}
+
+function startBusinessInput(overrides = {}) {
+ return {
+ business_id: "bakery",
+ owner: "research",
+ target_customer: "Independent bakery owners",
+ problem: "Weekly order reconciliation takes too long",
+ hypothesis: "A clearer order view will reduce reconciliation time",
+ confidence: 0.55,
+ source_reference: "interview://owner-1",
+ summary: "Owner spends two hours on reconciliation every week",
+ stance: "support",
+ provenance: "Interview note",
+ privacy_classification: "internal",
+ counterevidence: ["Interview objection about learning curve"],
+ alternatives: ["keep_manual_process", "use_spreadsheet_template"],
+ expected_outcome: "Weekly reconciliation takes less than one hour",
+ metric: "weekly_reconciliation_minutes",
+ decision: "run_bounded_validation",
+ review_date: "2026-07-24T12:00:00Z",
+ ...overrides,
+ };
+}
+
+function addEvidenceInput(overrides = {}) {
+ return {
+ source_reference: "interview://owner-2",
+ summary: "A second owner also wants the same flow",
+ stance: "contradict",
+ provenance: "Interview note",
+ privacy_classification: "internal",
+ decision_changes: { confidence: 0.65 },
+ ...overrides,
+ };
+}
+
+function createService(projectRoot, overrides = {}) {
+ return createGenesisService({
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ confirm: async () => true,
+ ...overrides,
+ });
+}
+
+function validateYAMLRecords(projectRoot, registry) {
+ for (const descriptor of listRecords(projectRoot)) {
+ const record = readRecord(descriptor.absolutePath);
+ if (descriptor.kind === "evidence") {
+ registry.validateEvidence(record);
+ } else {
+ registry.validateRecord(
+ descriptor.kind === "decision" ? "decision_record" : "experiment_record",
+ record,
+ );
+ }
+ }
+}
+
+test("projection failure preserves YAML and rebuilds cleanly", { concurrency: false }, async () => {
+ const projectRoot = makeProjectRoot();
+ const registry = createSchemaRegistry(ROOT);
+ try {
+ const service = createService(projectRoot, {
+ projectRecords: async () => {
+ throw new Error("projection adapter failed");
+ },
+ });
+
+ const result = await service.startBusiness(startBusinessInput());
+ assert.equal(result.changed, true);
+ assert.equal(result.projection_stale, true);
+ assert.equal(result.warning.code, "PROJECTION_STALE");
+
+ validateYAMLRecords(projectRoot, registry);
+ assert.equal(listRecords(projectRoot).length, 2);
+
+ const paths = workspacePaths(projectRoot);
+ fs.writeFileSync(path.join(paths.evidence, "bakery-evidence-999.v0001.yaml.tmp"), "not real yaml\n");
+
+ const rebuild = await service.rebuildIndex();
+ assert.deepEqual(rebuild, { recordCount: 2, businessCount: 1, projection_consistent: true });
+
+ const status = await service.status("bakery");
+ assert.equal(status.projection_consistent, true);
+ assert.equal(status.state, "discover");
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("lock conflict leaves YAML and SQLite unchanged", { concurrency: false }, async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const service = createService(projectRoot);
+ await service.startBusiness(startBusinessInput());
+
+ const paths = workspacePaths(projectRoot);
+ const beforeRecords = listRecords(projectRoot).map((record) => record.relativePath);
+ const beforeDb = fs.statSync(paths.db);
+
+ fs.writeFileSync(paths.lock, "held\n", { mode: 0o600 });
+ await assert.rejects(
+ () => service.addEvidence("bakery", addEvidenceInput()),
+ (error) => error.code === "WORKSPACE_LOCKED",
+ );
+
+ assert.deepEqual(listRecords(projectRoot).map((record) => record.relativePath), beforeRecords);
+ const afterDb = fs.statSync(paths.db);
+ assert.equal(afterDb.size, beforeDb.size);
+ assert.equal(afterDb.mtimeMs, beforeDb.mtimeMs);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("CLI output reports stale projection after a projection adapter failure", { concurrency: false }, async () => {
+ const projectRoot = makeProjectRoot();
+ const output = createBuffer();
+ try {
+ const service = createService(projectRoot, {
+ projectRecords: async () => {
+ throw new Error("projection adapter failed");
+ },
+ });
+
+ const exit = await runCli(["start-business"], {
+ projectRoot,
+ repoRoot: ROOT,
+ clock: CLOCK,
+ prompter: createScriptedPrompter([
+ "bakery",
+ "Independent bakery owners",
+ "Weekly order reconciliation takes too long",
+ "A clearer order view will reduce reconciliation time",
+ "0.55",
+ "interview://owner-1",
+ "Owner spends two hours on reconciliation every week",
+ "1",
+ "Interview note",
+ "1",
+ "Two owners object to learning curve",
+ "keep_manual_process,use_spreadsheet_template",
+ "Weekly reconciliation takes less than one hour",
+ "weekly_reconciliation_minutes",
+ "run_bounded_validation",
+ "research",
+ "2026-07-24T12:00:00Z",
+ "y",
+ ], output),
+ output,
+ errorOutput: output,
+ service,
+ });
+
+ assert.equal(exit, 0);
+ const text = output.toString().replaceAll("\r\n", "\n");
+ assert.equal(text.includes("PROJECTION_STALE"), true);
+ assert.equal(text.includes("Projection consistent: no"), true);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
diff --git a/tests/storage.test.mjs b/tests/storage.test.mjs
new file mode 100644
index 0000000..1313fce
--- /dev/null
+++ b/tests/storage.test.mjs
@@ -0,0 +1,274 @@
+import assert from "node:assert/strict";
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import test from "node:test";
+
+import { ensureWorkspace, workspacePaths, withWorkspaceLock } from "../src/storage/workspace.mjs";
+import {
+ listRecords,
+ readRecord,
+ recoverRecordTransactions,
+ writeRecord,
+ writeRecords,
+} from "../src/storage/yaml-record-store.mjs";
+
+function makeProjectRoot() {
+ return fs.mkdtempSync(path.join(os.tmpdir(), "genesis-storage-"));
+}
+
+function cleanupProjectRoot(projectRoot) {
+ fs.rmSync(projectRoot, { recursive: true, force: true });
+}
+
+test("workspace directories are private and deterministic", () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const paths = ensureWorkspace(projectRoot);
+
+ assert.equal(paths.root, path.join(projectRoot, ".genesis"));
+ for (const directory of [paths.root, paths.records, paths.decisions, paths.experiments, paths.evidence]) {
+ assert.equal(fs.existsSync(directory), true, directory);
+ assert.equal(fs.statSync(directory).mode & 0o777, 0o700, directory);
+ }
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("writeRecord stores immutable YAML and rejects duplicate versions", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const value = {
+ id: "bakery-decision",
+ record_type: "decision_record",
+ schema_version: "1.0.0",
+ policy_version: "2.0.0",
+ };
+
+ const written = await writeRecord({
+ projectRoot,
+ kind: "decision",
+ id: "bakery-decision",
+ version: 1,
+ value,
+ });
+
+ assert.equal(written.relativePath, ".genesis/records/decisions/bakery-decision.v0001.yaml");
+ assert.deepEqual(readRecord(written.absolutePath), value);
+
+ await assert.rejects(
+ () => writeRecord({
+ projectRoot,
+ kind: "decision",
+ id: "bakery-decision",
+ version: 1,
+ value,
+ }),
+ (error) => error.code === "RECORD_VERSION_EXISTS",
+ );
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("writeRecord rejects unsupported kinds", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ await assert.rejects(
+ () => writeRecord({
+ projectRoot,
+ kind: "invalid",
+ id: "bakery-decision",
+ version: 1,
+ value: {},
+ }),
+ (error) => error.code === "RECORD_KIND_INVALID",
+ );
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("writeRecords rolls back every published record when a batch collides", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ await writeRecord({
+ projectRoot,
+ kind: "evidence",
+ id: "existing",
+ version: 1,
+ value: { id: "existing" },
+ });
+
+ await assert.rejects(
+ () => writeRecords({
+ projectRoot,
+ records: [
+ { kind: "decision", id: "new-decision", version: 1, value: { id: "new-decision" } },
+ { kind: "evidence", id: "existing", version: 1, value: { id: "collision" } },
+ ],
+ }),
+ (error) => error.code === "RECORD_VERSION_EXISTS",
+ );
+
+ assert.equal(
+ fs.existsSync(path.join(workspacePaths(projectRoot).decisions, "new-decision.v0001.yaml")),
+ false,
+ );
+ assert.deepEqual(
+ readRecord(path.join(workspacePaths(projectRoot).evidence, "existing.v0001.yaml")),
+ { id: "existing" },
+ );
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("concurrent writes cannot replace an immutable record version", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const results = await Promise.allSettled([
+ writeRecord({ projectRoot, kind: "decision", id: "race", version: 1, value: { winner: "left" } }),
+ writeRecord({ projectRoot, kind: "decision", id: "race", version: 1, value: { winner: "right" } }),
+ ]);
+ assert.equal(results.filter((result) => result.status === "fulfilled").length, 1);
+ const rejected = results.find((result) => result.status === "rejected");
+ assert.equal(rejected.reason.code, "RECORD_VERSION_EXISTS");
+ assert.equal(
+ ["left", "right"].includes(readRecord(path.join(workspacePaths(projectRoot).decisions, "race.v0001.yaml")).winner),
+ true,
+ );
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("interrupted record publication rolls back from its transaction journal", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const paths = ensureWorkspace(projectRoot);
+ const stagedPath = path.join(paths.decisions, ".interrupted.staged");
+ const finalPath = path.join(paths.decisions, "interrupted.v0001.yaml");
+ fs.writeFileSync(stagedPath, "id: interrupted\n", { mode: 0o600 });
+ fs.linkSync(stagedPath, finalPath);
+ const journalDirectory = path.join(paths.root, ".transactions");
+ fs.mkdirSync(journalDirectory, { recursive: true, mode: 0o700 });
+ fs.writeFileSync(path.join(journalDirectory, "interrupted.json"), JSON.stringify({
+ pid: 999999999,
+ entries: [{
+ stagedPath: path.relative(projectRoot, stagedPath),
+ finalPath: path.relative(projectRoot, finalPath),
+ }],
+ }));
+
+ await recoverRecordTransactions(projectRoot);
+ assert.equal(fs.existsSync(stagedPath), false);
+ assert.equal(fs.existsSync(finalPath), false);
+ assert.deepEqual(fs.readdirSync(journalDirectory), []);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("withWorkspaceLock excludes competing locks and cleans up the lock file", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const paths = workspacePaths(projectRoot);
+
+ await withWorkspaceLock(projectRoot, async () => {
+ assert.equal(fs.existsSync(paths.lock), true);
+
+ await assert.rejects(
+ withWorkspaceLock(projectRoot, async () => {}),
+ (error) => error.code === "WORKSPACE_LOCKED",
+ );
+
+ assert.equal(fs.existsSync(paths.lock), true);
+ });
+
+ assert.equal(fs.existsSync(paths.lock), false);
+
+ await assert.rejects(
+ withWorkspaceLock(projectRoot, async () => {
+ throw new Error("boom");
+ }),
+ /boom/,
+ );
+
+ assert.equal(fs.existsSync(paths.lock), false);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("withWorkspaceLock reclaims confirmed stale locks and preserves ambiguous locks", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ const paths = ensureWorkspace(projectRoot);
+ fs.writeFileSync(paths.lock, `999999999\n2026-07-17T00:00:00.000Z\n`, { mode: 0o600 });
+ assert.equal(await withWorkspaceLock(projectRoot, async () => "recovered"), "recovered");
+ assert.equal(fs.existsSync(paths.lock), false);
+
+ fs.writeFileSync(paths.lock, "unknown owner\n", { mode: 0o600 });
+ await assert.rejects(
+ withWorkspaceLock(projectRoot, async () => {}),
+ (error) => error.code === "WORKSPACE_LOCKED" && error.correction.includes("manually"),
+ );
+ assert.equal(fs.existsSync(paths.lock), true);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});
+
+test("listRecords sorts by kind, id, and version and ignores tmp files", async () => {
+ const projectRoot = makeProjectRoot();
+ try {
+ await writeRecord({
+ projectRoot,
+ kind: "experiment",
+ id: "beta",
+ version: 2,
+ value: { id: "beta", version: 2 },
+ });
+ await writeRecord({
+ projectRoot,
+ kind: "decision",
+ id: "alpha",
+ version: 2,
+ value: { id: "alpha", version: 2 },
+ });
+ await writeRecord({
+ projectRoot,
+ kind: "decision",
+ id: "alpha",
+ version: 1,
+ value: { id: "alpha", version: 1 },
+ });
+ await writeRecord({
+ projectRoot,
+ kind: "evidence",
+ id: "alpha",
+ version: 1,
+ value: { id: "alpha", version: 1 },
+ });
+
+ const tempPath = path.join(
+ workspacePaths(projectRoot).records,
+ "decisions",
+ ".ignored.v0009.yaml.tmp",
+ );
+ fs.writeFileSync(tempPath, "temporary");
+
+ const records = listRecords(projectRoot);
+ assert.deepEqual(records.map(({ kind, id, version }) => ({ kind, id, version })), [
+ { kind: "decision", id: "alpha", version: 1 },
+ { kind: "decision", id: "alpha", version: 2 },
+ { kind: "evidence", id: "alpha", version: 1 },
+ { kind: "experiment", id: "beta", version: 2 },
+ ]);
+ assert.equal(records.every((record) => !record.relativePath.includes(".tmp")), true);
+ } finally {
+ cleanupProjectRoot(projectRoot);
+ }
+});