From 69353e25ce7a62446d6e35f870cbf27a3cfacb84 Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Fri, 17 Jul 2026 22:45:24 +0400 Subject: [PATCH 01/18] Design CLI discovery pipeline --- ...7-genesis-cli-discovery-pipeline-design.md | 235 ++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/superpowers/specs/2026-07-17-genesis-cli-discovery-pipeline-design.md diff --git a/docs/superpowers/specs/2026-07-17-genesis-cli-discovery-pipeline-design.md b/docs/superpowers/specs/2026-07-17-genesis-cli-discovery-pipeline-design.md new file mode 100644 index 0000000..869a2dc --- /dev/null +++ b/docs/superpowers/specs/2026-07-17-genesis-cli-discovery-pipeline-design.md @@ -0,0 +1,235 @@ +# Genesis CLI Discovery Pipeline Design + +Policy-Version: 2.0.0 +Authority: Explanatory +Status: User-reviewed design +Date: 2026-07-17 +External-Decision: Decide whether to implement the first operational Genesis pipeline as an offline, human-assisted CLI covering Discover through validation planning. +Owner: builder +Review-Date: 2026-08-17 +Sunset-Date: 2027-01-17 + +## 1. Purpose + +This design defines the first executable operating slice built on Genesis 2.0. It turns the governance package into a resumable local CLI that can register one business opportunity, preserve evidence, create a Decision Record, preregister a validation experiment, calculate supported early metrics, and stop at the approval gate before real-world execution. + +The design does not authorize normative policy changes or protected actions. Any implementation change to a normative schema or validator must follow the applicable decision class, records, versioning, tests, and approval requirements before it becomes effective. + +## 2. Decisions approved during design review + +- The first interface is a CLI rather than a web application. +- Runtime state uses canonical immutable YAML records with a rebuildable SQLite projection. +- The workflow is human-assisted; suggestions cannot save evidence or advance state. +- `genesis start-business` is a guided interactive wizard. +- The first release covers Discover through validation planning and stops at `approval_pending`. +- The CLI works without an external AI API or network access. +- Work is resumable across commands rather than requiring one long session. + +## 3. Goals + +- Create a real, policy-valid opportunity and Decision Record from a guided CLI. +- Add supporting and contradicting evidence without silently rewriting history. +- Explain current lifecycle state, missing gates, permitted next action, and blockers. +- Produce a complete preregistered validation Experiment Record. +- Enforce the approval boundary before experiment execution. +- Preserve YAML as durable evidence while making state and metrics practical through SQLite. +- Recover the complete projection from YAML alone. +- Produce actionable fail-closed errors. + +## 4. Non-goals + +Version one does not: + +- Search the web or call an AI service. +- Contact customers or conduct an experiment. +- Build or deploy a product. +- Enter contracts, spend money, process payments, or handle financial authority. +- Collect or copy sensitive files. +- Implement Build, Launch, Operate, Review, Scale, Pivot, Learning Lab, Archive, or Kill execution. +- Provide a web UI, multi-user service, production database, billing, outreach, or autonomous agents. +- Calculate outcome metrics that require an executed experiment. + +## 5. Architecture + +The implementation remains a Node.js application in the existing repository and is divided into focused components. + +### 5.1 CLI layer + +The CLI owns terminal input and output only. It collects guided answers, displays complete proposals, requests explicit confirmation, and renders success or actionable errors. It does not contain policy, storage, or lifecycle logic. + +### 5.2 Workflow service + +The workflow service loads the normative Genesis policy set, identifies the current lifecycle state, evaluates gates, and returns allowed actions or blockers. It consumes canonical records rather than ad hoc helper shapes. + +### 5.3 Record service + +The record service creates and validates versioned Decision and Experiment Records. Confirmed changes produce new immutable YAML versions. It never rewrites a prior canonical record. + +### 5.4 YAML record store + +YAML is the durable evidence layer. Records are written to a temporary file, validated, and atomically renamed into place. Stable record identifiers and monotonically increasing version numbers identify history. + +### 5.5 SQLite projection + +SQLite stores current lifecycle state, latest record versions, searchable fields, record paths, and supported metrics. It is not authoritative. It may be deleted and rebuilt deterministically from YAML. + +### 5.6 Suggestion provider + +The first provider is deterministic and offline. It offers examples of hypotheses, evidence categories, validation methods, metrics, and stop conditions. Suggestions are labeled, require explicit user selection or editing, and never become evidence automatically. A provider interface allows a future AI adapter without coupling AI behavior to policy or storage. + +## 6. Runtime workspace + +Each working directory receives a private local runtime directory: + +```text +.genesis/ +├── records/ +│ ├── decisions/ +│ └── experiments/ +├── evidence/ +├── genesis.db +└── workspace.lock +``` + +Runtime data is ignored by Git by default. Privacy classification defaults to `internal`. Evidence files are not copied automatically; version one stores user-confirmed references and summaries. + +## 7. Commands and user flow + +### 7.1 `genesis start-business` + +The guided wizard collects the business identifier, target customer, problem, hypothesis, confidence, initial evidence, counterevidence, alternatives, expected outcome, metric, owner, and review date. It shows the complete proposed Decision Record before confirmation. + +After confirmation, the command creates the first immutable Decision Record, initializes the SQLite projection, and places the opportunity in `discover`. + +### 7.2 `genesis add-evidence ` + +The command collects a source reference, summary, collection date, privacy classification, provenance, and whether the item supports or contradicts the current hypothesis. It shows the proposed addition before confirmation. + +The command appends evidence and creates a new Decision Record version when belief, confidence, or the current evidence set changes. Prior versions remain intact. + +### 7.3 `genesis status ` + +The command displays: + +- Current lifecycle state. +- Latest Decision and Experiment Record versions. +- Supporting and contradicting evidence counts. +- Missing exit criteria and preregistration fields. +- Current cash, labor, duration, data, and risk limits. +- The next permitted command. +- Approval, policy, or record blockers. +- SQLite projection consistency. + +### 7.4 `genesis plan-experiment ` + +This command is available only when Discover exit criteria pass. It collects the supported decision, baseline, comparison method, metric formula, population, denominator, data source, expected outcome, minimum meaningful effect, failure conditions, stop conditions, maximum cash, labor, duration, data and risk exposure, owner, decision date, and allowed outcomes. + +After confirmation, it creates a preregistered draft Experiment Record and sets the projected state to `approval_pending`. Version one cannot run the experiment or bypass this state. + +### 7.5 `genesis rebuild-index` + +The command deletes no canonical evidence. It reads every valid YAML version in deterministic order, rebuilds SQLite in a temporary database, verifies the resulting projection, and atomically replaces the stale database. + +## 8. Record and validator alignment + +The current Experiment Record schema requires closure-only fields even when a record is a draft. The current transition helper also expects approval and validation objects whose fields do not match canonical records. + +Implementation therefore requires a separately authorized normative change that: + +- Makes Experiment Record requirements conditional on lifecycle status. +- Requires preregistration fields for `draft` and `active` records. +- Requires actual cost, results, reflection, outcome, confidence update, decision outcome, and Experience linkage only when the record is `closed`. +- Defines a canonical validation subtype and outcome representation that can satisfy the Business Lifecycle Build gate in a later release. +- Makes transition validation consume canonical Approval and Experiment Record fields. +- Validates actor, action, scope, budget, duration, data class, risk class, effective time, expiry, and revocation from the canonical Approval Record. +- Updates schemas, templates, focused tests, negative fixtures, explanatory documentation, and the complete repository gate together. + +This design records the need; it does not itself grant approval or make the change effective. + +## 9. Write sequence and recovery + +Every durable command follows the same sequence: + +1. Collect input. +2. Validate field-level constraints. +3. Display the complete proposed record or evidence addition. +4. Receive explicit confirmation. +5. Validate the proposal against schema, policy, lifecycle, and authority requirements. +6. Write and flush a temporary YAML file. +7. Atomically rename it into the canonical record store. +8. Update SQLite. +9. Verify the projection references the new canonical version. + +If steps 1 through 5 fail, nothing is written. If the process stops before the rename, no canonical partial record exists. If SQLite update or verification fails after the rename, the command reports that evidence is safe but the projection is stale and directs the user to `genesis rebuild-index`. + +## 10. Safety and privacy + +- A workspace lock prevents concurrent writers. +- Missing, invalid, contradictory, ambiguous, expired, revoked, or mismatched authority fails closed. +- Conversation context is never treated as approval. +- Approval requires a schema-valid canonical Approval Record. +- Protected actions remain unavailable in version one. +- Suggestions are visually identified and cannot be cited as evidence without a human-provided source and confirmation. +- Runtime data remains local and Git-ignored by default. +- Privacy defaults to `internal`; version one rejects sensitive-data collection workflows. +- No command makes network calls. +- Stable errors include code, affected record or field, expected correction, and escalation target. + +## 11. Supported metrics + +Version one calculates only metrics supported by its actual data: + +- Supporting evidence count. +- Contradicting evidence count. +- Days spent in Discover. +- Time from opportunity creation to validation plan. +- Preregistration completeness. +- Decision confidence and version history. +- Commands blocked by policy or error code. +- Record and projection consistency. + +It does not calculate realized value, experiment success, customer-reality ratio, forecast calibration, or other outcome metrics before an experiment is executed. + +## 12. Testing strategy + +### 12.1 Unit tests + +Cover identifiers, deterministic suggestions, classifications, record versioning, gate evaluation, and metric calculations. + +### 12.2 Schema and policy tests + +Prove that each record status requires the correct fields, invalid phase combinations fail, canonical approvals enforce every limit, and the existing negative fixtures remain rejected. + +### 12.3 Storage tests + +Cover atomic YAML writes, append-only behavior, interrupted writes, workspace locking, projection updates, projection mismatch detection, and deterministic SQLite rebuilding. + +### 12.4 CLI integration tests + +Run commands in isolated temporary workspaces with scripted input. Verify terminal output, exit codes, YAML contents, version history, SQLite state, and fail-closed behavior. + +### 12.5 No-network test + +Run the complete CLI flow with network access unavailable and assert that no network-capable adapter is invoked. + +## 13. Acceptance criteria + +The first release is complete only when a clean environment can: + +1. Run `genesis start-business` and create a schema-valid opportunity and Decision Record. +2. Resume the opportunity with `genesis add-evidence`. +3. Show missing Discover gates with `genesis status`. +4. Produce a complete preregistered Experiment Record with `genesis plan-experiment`. +5. Stop at `approval_pending` and reject execution without valid approval. +6. Delete SQLite, rebuild it from YAML, and recover identical state and metrics. +7. Reject missing, expired, revoked, actor-mismatched, scope-mismatched, and limit-mismatched approvals. +8. Pass focused tests and the complete `npm run check` gate. +9. Demonstrate that the full version-one flow works without network access or an API key. + +## 14. Later releases + +Later designs may separately add experiment execution and measurement, AI-assisted public research, a web approval interface, full business lifecycle states, customer outreach, product building, deployment, billing, and operations. Each addition receives its own design, plan, authority classification, evidence, and verification. + +## 15. Review and sunset + +The owner reviews this design by 2026-08-17 against implementation evidence, system-overhead ratio, and the external decision it supports. The design sunsets on 2027-01-17 unless renewed, superseded, or closed with recorded rationale. From 5daa7fc8d11c98d9aebf5b596d34405b1cb7172d Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Fri, 17 Jul 2026 23:26:16 +0400 Subject: [PATCH 02/18] Plan Genesis CLI discovery pipeline --- ...26-07-17-genesis-cli-discovery-pipeline.md | 884 ++++++++++++++++++ 1 file changed, 884 insertions(+) create mode 100644 docs/superpowers/plans/2026-07-17-genesis-cli-discovery-pipeline.md diff --git a/docs/superpowers/plans/2026-07-17-genesis-cli-discovery-pipeline.md b/docs/superpowers/plans/2026-07-17-genesis-cli-discovery-pipeline.md new file mode 100644 index 0000000..3b4f618 --- /dev/null +++ b/docs/superpowers/plans/2026-07-17-genesis-cli-discovery-pipeline.md @@ -0,0 +1,884 @@ +# Genesis CLI Discovery Pipeline 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:** Deliver an offline, resumable `genesis` CLI that creates immutable Decision and Experiment YAML records, maintains a rebuildable SQLite projection, reports supported early metrics, and stops at `approval_pending` before any real-world experiment execution. + +**Architecture:** The CLI is a thin terminal adapter over an application service. The service validates proposals against canonical Genesis schemas and workflow rules, writes append-only YAML first, and then updates a disposable SQLite projection. Deterministic offline suggestions assist the human without becoming evidence or advancing state. All state-changing commands are confirmed, locked, fail closed, and recoverable from YAML. + +**Tech Stack:** Node.js 22, ECMAScript modules, npm, `yaml` 2.8.3, Ajv 8.18.0, `ajv-formats` 3.0.1, `better-sqlite3` 12.11.1, Node's built-in test runner, JSON Schema Draft 2020-12, YAML 1.2, SQLite 3. + +## Global Constraints + +- External decision: determine whether one human-confirmed opportunity is ready to leave Discover and receive a validation plan. +- Owner: `builder`. Review date: 2026-08-17. Sunset date: 2027-01-17. +- Before Task 1, execution must find and validate `records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml`. It must be an active, unexpired Human Authority `protected_action` record for actor `codex-agent` and action `sandboxed_cli_pipeline_implementation`. The evidence snapshot and rationale must identify the Decision/Experiment schema alignment, transition-validator alignment, offline CLI, local YAML storage, and rebuildable SQLite projection. Its envelope must allow USD 0 cash, eight labor hours, seven calendar days, `public` and `internal` data, and `high` risk. The implementer must not create, infer, broaden, or repair this approval. +- If the approval record is absent, expired, revoked, ambiguous, or mismatched, stop before changing normative files and report the exact validation issue to `genesis-owner`. Read-only inspection remains allowed. +- `genesis.yaml` and its referenced YAML and schemas remain normative. Markdown and this plan are explanatory and cannot grant authority. +- YAML under `.genesis/` is canonical runtime evidence. SQLite is a derived projection and may be deleted and rebuilt. +- Every confirmed record version is immutable. A correction creates the next version and links the preceding path through `immutable_history_refs`. +- Runtime data is local and Git-ignored. Version one stores user-entered references and summaries; it never copies referenced files. +- Privacy defaults to `internal`. Version one rejects `restricted` evidence and any workflow that proposes sensitive-data collection. +- The CLI has no network adapter, API key, autonomous agent, experiment runner, outreach, deployment, billing, payment, or production capability. +- Suggestions are deterministic, visibly labeled, and never count as evidence without a human-supplied source reference and explicit confirmation. +- `plan-experiment` creates a `draft` Experiment Record and projected `approval_pending` state. No version-one command can enter `running`. +- Stable errors include `code`, affected `path`, expected `correction`, and `escalation` target. Missing or contradictory state fails closed. +- Each task uses red-green-refactor: add one focused failing test, observe the expected failure, add the smallest implementation, rerun the focused test, then commit. +- Run `npm run check` after every normative task and before completion. Do not push or open a pull request without separately valid publication authority. + +## File Responsibility Map + +- `bin/genesis.mjs`: executable entry point and process exit code. +- `src/cli/run-cli.mjs`: argument routing and command orchestration. +- `src/cli/prompter.mjs`: injectable terminal questions and confirmation. +- `src/cli/render.mjs`: stable human-readable proposals, status, warnings, and errors. +- `src/application/genesis-service.mjs`: transactional application operations. +- `src/core/errors.mjs`: stable error type and formatting. +- `src/core/ids.mjs`: business IDs and immutable version filenames. +- `src/core/suggestions.mjs`: deterministic offline suggestions. +- `src/core/schema-registry.mjs`: Ajv compilation for canonical and runtime schemas. +- `src/core/record-builders.mjs`: canonical Decision, Experiment, and evidence construction. +- `src/core/discovery-workflow.mjs`: Discover gates, experiment completeness, and current status. +- `src/core/metrics.mjs`: only metrics supported by local evidence. +- `src/storage/workspace.mjs`: paths, private directories, and writer lock. +- `src/storage/yaml-record-store.mjs`: append-only atomic YAML persistence. +- `src/storage/projection.mjs`: SQLite projection, consistency check, and rebuild. +- `schemas/records/*.schema.json`: normative canonical record shapes. +- `schemas/runtime/evidence-entry.schema.json`: local evidence-entry shape; not a normative record-template registration. +- `tests/*.test.mjs`: focused unit, storage, service, CLI, recovery, and no-network behavior. + +--- + +### Task 1: Verify authority and align canonical record/transition contracts + +**Files:** +- Require: `records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml` +- Modify: `schemas/records/decision-record.schema.json` +- Modify: `schemas/records/experiment-record.schema.json` +- Modify: `config/workflows/experiment-lifecycle.yaml` +- Modify: `templates/decision-record.yaml` +- Modify: `templates/experiment-record.yaml` +- Modify: `scripts/validate-genesis.mjs` +- Modify: `tests/records.test.mjs` +- Modify: `tests/invariants.test.mjs` + +**Interfaces:** +- Extends: `validateApproval(record, { now, action, actor, limits? }) -> ValidationIssue[]` +- Changes: `validateTransition(policySet, workflowId, from, to, context) -> ValidationIssue[]` +- Consumes canonical `context.records`, `context.approvals`, and `context.experimentRecord`; removes ad hoc approval, record-type, preregistration-field, and closure-field shapes. + +- [ ] **Step 1: Validate execution authority before touching normative files** + +Run: + +```bash +test -f records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml +npm run validate +node --input-type=module -e "import fs from 'node:fs'; import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { parseYamlFile, validateApproval } from './scripts/validate-genesis.mjs'; const p='records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml'; const r=parseYamlFile(p); const a=new Ajv2020({allErrors:true,strict:true}); addFormats(a); const schema=JSON.parse(fs.readFileSync('schemas/records/approval-record.schema.json','utf8')); const ok=a.compile(schema)(r); const e=validateApproval(r,{now:new Date().toISOString(),action:'sandboxed_cli_pipeline_implementation',actor:'codex-agent'}); const envelope=r.limits?.cash_usd>=0&&r.limits?.labor_hours>=8&&r.limits?.duration_days>=7&&['public','internal'].every(x=>r.limits?.data_classes?.includes(x))&&['high','critical'].includes(r.limits?.risk_level); const authority=r.status==='active'&&r.approver_role==='human_authority'&&r.approver_principal_id==='genesis-owner'&&r.action_class==='protected_action'; if(!ok||e.length||!envelope||!authority){console.error(JSON.stringify({schema:a.errors,approval:e,envelope,authority},null,2));process.exit(1)}" +``` + +Expected: all commands exit 0. If any command fails, stop this plan and escalate to `genesis-owner`; do not continue to Step 2. + +- [ ] **Step 2: Replace ad hoc transition fixtures with canonical record-shaped fixtures** + +In `tests/invariants.test.mjs`, remove `COMPLETE_PREREGISTRATION`. Add helpers that clone the schema-valid templates and override only fields relevant to the test: + +```js +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, + effective_at: "2026-07-17T00:00:00Z", + expires_at: "2026-07-19T00:00:00Z", + review_at: "2026-07-18T00:00:00Z", + ...overrides, + }); +} +``` + +Add assertions for: + +```js +const passedValidation = await canonicalRecord("experiment_record", { + subtype: "validation", + status: "closed", + validation_outcome: "passed", +}); +assert.deepEqual(transitionIssues(policySet, "business_lifecycle", "validate", "build", { + records: [passedValidation], now: "2026-07-18T00:00:00Z", actor: "codex-agent", +}), []); + +const draft = await canonicalRecord("experiment_record", { + status: "draft", approval_references: [], +}); +assert.deepEqual(transitionIssues(policySet, "experiment_lifecycle", "approval", "running", { + experimentRecord: draft, + approvals: [await activeApproval("experiment", "ceo")], + now: "2026-07-18T00:00:00Z", + actor: "codex-agent", +}), []); +``` + +Also assert that an expired approval, wrong actor, missing preregistration value, and non-closed validation record each produce the existing stable gate code. Add direct `validateApproval` cases in `tests/records.test.mjs` proving requested cash, labor, duration, data classes, and risk cannot exceed the canonical approval envelope; each mismatch must produce `APPROVAL_LIMIT_MISMATCH`. + +Assert that the workflow's preregistration and closure names resolve directly against an Experiment Record. The canonical nested paths are `metric.formula`, `metric.population`, `metric.denominator`, `metric.data_source`, `limits.cash_usd`, `limits.labor_hours`, `limits.duration_days`, `limits.data_classes`, and `limits.risk_level`; the canonical Experience link is `experience_reference`. + +- [ ] **Step 3: Run the transition tests and observe the contract mismatch** + +Run: `node --test --test-name-pattern='Build requires|experiment Approval|experiment closure' tests/invariants.test.mjs` + +Expected: FAIL because `validateTransition` still reads `recordTypes`, `preregistrationFields`, `closureFields`, and `{ approver, action, valid }`. + +- [ ] **Step 4: Write failing record-schema tests for lifecycle-conditional requirements** + +In `tests/records.test.mjs`, add tests proving: + +```js +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); +}); +``` + +- [ ] **Step 5: Run the schema tests and verify they fail for the intended reasons** + +Run: `node --test --test-name-pattern='draft experiment|closed validation|target customer' tests/records.test.mjs` + +Expected: FAIL because closure fields are always required, the validation fields do not exist, and `target_customer` is not required. + +- [ ] **Step 6: Make the smallest normative schema and template alignment** + +In `decision-record.schema.json`, require and define: + +```json +"target_customer": { "$ref": "#/$defs/nonempty" } +``` + +In `experiment-record.schema.json`: + +- add `subtype` with enum `validation`; +- add `validation_outcome` with enum `pending`, `passed`, `failed`; +- add `confidence_update` as number-or-null from 0 through 1; +- add `decision_outcome` as null or one of `scale`, `pivot`, `learning_lab`, `archive`, `kill`; +- keep every preregistration field in the base `required` array; +- remove closure fields from the base `required` array; +- change `approval_references` to `recordRefs` at the property level; +- add an `allOf` branch requiring nonempty approval references for `active` and `closed`; +- add an `allOf` branch requiring all closure fields for `closed` and restricting `validation_outcome` to `passed` or `failed`. + +Use this conditional shape: + +```json +{ + "if": { "properties": { "status": { "const": "closed" } }, "required": ["status"] }, + "then": { + "required": ["actual_cost", "results", "reflection", "outcome", "experience_reference", "confidence_update", "decision_outcome"], + "properties": { "validation_outcome": { "enum": ["passed", "failed"] } } + } +} +``` + +Update the example templates with `target_customer`, `subtype: validation`, `validation_outcome: passed`, `confidence_update: 0.60`, and `decision_outcome: pivot`. + +In `config/workflows/experiment-lifecycle.yaml`, replace the conceptual aliases `metric_formula`, `metric_population`, `metric_denominator`, `metric_data_source`, `maximum_cash`, `maximum_labor`, `maximum_duration`, `maximum_data`, and `maximum_risk` with the canonical nested paths listed in Step 2. Replace `linked_experience_record` with `experience_reference` in `closure_required_fields` and state evidence lists. This lets the validator resolve the normative path rather than maintaining a second alias map. + +- [ ] **Step 7: Make transition validation consume canonical records** + +Replace `validApproval` with: + +```js +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 + )); +} +``` + +Extend `validateApproval` with an optional requested `limits` object. Compare cash, labor, and duration numerically; require every requested data class to appear in the approved `data_classes`; and compare risk using `low < medium < high < critical`. Any excess or unknown value returns `APPROVAL_LIMIT_MISMATCH` at `/limits`. Pass the Experiment Record's `limits` through `matchingApproval` for Approval to Running and pass the bounded learning-prototype limits for its exception path. + +For Validate to Build, accept only: + +```js +const passedValidation = context.records?.some((record) => ( + record?.record_type === "experiment_record" + && record.subtype === "validation" + && record.status === "closed" + && record.validation_outcome === "passed" +)); +``` + +For Approval to Running, validate `context.experimentRecord` against the compiled Experiment schema, resolve every dot-separated workflow preregistration path against that record, confirm each value is nonempty, and call `matchingApproval` using `context.actor`, `context.now`, and the record's `limits`. For Decision to Closed, require a canonical closed Experiment record with every configured closure path. Preserve existing error codes and paths. + +- [ ] **Step 8: Run focused and complete gates** + +Run: + +```bash +node --test tests/records.test.mjs tests/invariants.test.mjs +npm run check +``` + +Expected: both exit 0; all templates remain schema-valid and all negative fixtures remain rejected. + +- [ ] **Step 9: Commit the canonical-contract change** + +```bash +git add records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml schemas/records/decision-record.schema.json schemas/records/experiment-record.schema.json config/workflows/experiment-lifecycle.yaml templates/decision-record.yaml templates/experiment-record.yaml scripts/validate-genesis.mjs tests/records.test.mjs tests/invariants.test.mjs +git commit -m "Align canonical discovery record contracts" +``` + +--- + +### Task 2: Add the runtime package, stable errors, identifiers, and suggestions + +**Files:** +- Modify: `package.json` +- Modify: `package-lock.json` +- Create: `bin/genesis.mjs` +- Create: `src/core/errors.mjs` +- Create: `src/core/ids.mjs` +- Create: `src/core/suggestions.mjs` +- Create: `tests/cli-core.test.mjs` + +**Interfaces:** +- `GenesisError(code, message, { path, correction, escalation, cause })` +- `formatError(error) -> string` +- `normalizeBusinessId(value) -> string` +- `versionFileName(id, version) -> string` +- `suggestionsFor(topic) -> readonly string[]` + +- [ ] **Step 1: Write failing core tests** + +Create `tests/cli-core.test.mjs`: + +```js +import assert from "node:assert/strict"; +import test from "node:test"; +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); +}); +``` + +- [ ] **Step 2: Observe missing modules** + +Run: `node --test tests/cli-core.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/core/errors.mjs`. + +- [ ] **Step 3: Add runtime dependencies and executable metadata** + +Move `ajv`, `ajv-formats`, and `yaml` from `devDependencies` to `dependencies`; add `better-sqlite3: 12.11.1`; and add: + +```json +"bin": { "genesis": "./bin/genesis.mjs" } +``` + +Run: `npm install` + +Expected: exit 0, lockfile version 3, and `npm ls better-sqlite3` reports `12.11.1`. + +- [ ] **Step 4: Implement the minimal core interfaces** + +`GenesisError` must set `name`, `code`, `path`, `correction`, `escalation`, and optional `cause`. `normalizeBusinessId` must lowercase, replace non-alphanumerics with single hyphens, trim hyphens, and enforce the canonical ID regex. `versionFileName` must reject nonpositive/noninteger versions. `suggestionsFor` must return frozen copies from a closed map of supported topics. + +Create `bin/genesis.mjs` with a shebang and a temporary call to the later `runCli` module: + +```js +#!/usr/bin/env node +import { runCli } from "../src/cli/run-cli.mjs"; +process.exitCode = await runCli(process.argv.slice(2)); +``` + +Do not invoke the binary until Task 8 creates `run-cli.mjs`. + +- [ ] **Step 5: Run tests and commit** + +```bash +node --test tests/cli-core.test.mjs +npm run check +git add package.json package-lock.json bin/genesis.mjs src/core tests/cli-core.test.mjs +git commit -m "Add Genesis CLI runtime foundation" +``` + +Expected: tests and full gate exit 0. + +--- + +### Task 3: Validate and build canonical runtime records + +**Files:** +- Create: `schemas/runtime/evidence-entry.schema.json` +- Create: `src/core/schema-registry.mjs` +- Create: `src/core/record-builders.mjs` +- Create: `tests/record-builders.test.mjs` + +**Interfaces:** +- `createSchemaRegistry(repoRoot) -> { validateRecord(recordType, value), validateEvidence(value) }` +- `buildEvidenceEntry(input, clock) -> EvidenceEntry` +- `buildDecisionRecord(input, clock) -> DecisionRecord` +- `versionDecisionRecord(previous, changes, historyRef, clock) -> DecisionRecord` +- `buildExperimentRecord(input, clock) -> ExperimentRecord` + +- [ ] **Step 1: Write failing schema-registry and builder tests** + +Use a fixed clock `() => new Date("2026-07-18T00:00:00Z")`. Assert that: + +```js +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); + +const decision = buildDecisionRecord(validDecisionInput, clock); +assert.equal(registry.validateRecord("decision_record", decision), decision); +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"]); +``` + +Also assert that `restricted` evidence throws `SENSITIVE_DATA_FORBIDDEN`, invalid confidence throws `RECORD_SCHEMA_INVALID`, and an Experiment draft has `approval_references: []`, `validation_outcome: pending`, and no closure-only fields. + +- [ ] **Step 2: Run and observe missing modules** + +Run: `node --test tests/record-builders.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Define the evidence schema** + +Create a closed JSON schema requiring exactly: + +```json +{ + "id": "canonical-id", + "business_id": "canonical-id", + "collected_at": "date-time", + "source_reference": "nonempty string", + "summary": "nonempty string", + "stance": "support | contradict", + "provenance": "nonempty string", + "privacy_classification": "public | internal | confidential | restricted" +} +``` + +Set `additionalProperties: false`. Do not register this file in `genesis.yaml`; it is a runtime entry, not a new canonical record type. + +- [ ] **Step 4: Implement registry and builders** + +Compile the manifest-registered record schemas with Ajv 2020 and `ajv-formats`. Return the original value on success. On failure, throw `GenesisError("RECORD_SCHEMA_INVALID", "Record failed its registered schema", { path: firstInstancePath, correction: allAjvMessages, escalation: "builder" })`. + +Builders must: + +- use policy version `2.0.0` and schema version `1.0.0`; +- create stable IDs from the business ID; +- set both timestamps from the injected clock; +- default privacy to `internal`; +- never synthesize evidence, counterevidence, approval, results, or outcome; +- validate after construction, before returning. + +- [ ] **Step 5: Run tests and commit** + +```bash +node --test tests/record-builders.test.mjs +npm run check +git add schemas/runtime/evidence-entry.schema.json src/core/schema-registry.mjs src/core/record-builders.mjs tests/record-builders.test.mjs +git commit -m "Build schema-valid discovery records" +``` + +--- + +### Task 4: Add private workspaces and append-only atomic YAML storage + +**Files:** +- Modify: `.gitignore` +- Create: `src/storage/workspace.mjs` +- Create: `src/storage/yaml-record-store.mjs` +- Create: `tests/storage.test.mjs` + +**Interfaces:** +- `workspacePaths(projectRoot) -> { root, records, decisions, experiments, evidence, db, lock }` +- `ensureWorkspace(projectRoot) -> WorkspacePaths` +- `withWorkspaceLock(projectRoot, operation) -> Promise` +- `writeRecord({ projectRoot, kind, id, version, value }) -> { absolutePath, relativePath }` +- `readRecord(absolutePath) -> object` +- `listRecords(projectRoot) -> RecordDescriptor[]` + +- [ ] **Step 1: Write failing storage tests** + +In a fresh `mkdtemp` directory, assert: + +```js +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(await readRecord(written.absolutePath), value); +await assert.rejects(() => writeRecord({ projectRoot, kind: "decision", id: "bakery-decision", version: 1, value }), { code: "RECORD_VERSION_EXISTS" }); +``` + +Acquire one lock and assert a nested competing lock fails with `WORKSPACE_LOCKED`; then assert the lock file is removed after both success and thrown-operation paths. Assert `listRecords` sorts by kind, ID, then numeric version and ignores `.tmp` files. + +- [ ] **Step 2: Observe missing modules** + +Run: `node --test tests/storage.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implement private workspace creation and locking** + +Add `.genesis/` to `.gitignore`. `ensureWorkspace` creates directories with mode `0700`. `withWorkspaceLock` opens `.genesis/workspace.lock` with flag `wx`, writes the PID and timestamp, and removes only the exact lock it created in `finally`. An existing lock fails closed; version one does not guess whether it is stale. + +- [ ] **Step 4: Implement append-only atomic writes** + +For each write: + +1. validate `kind` against `decision`, `experiment`, `evidence`; +2. create the final version filename deterministically; +3. fail if the final path exists; +4. serialize with `YAML.stringify`; +5. create a same-directory unique `.tmp` file with mode `0600` and exclusive creation; +6. write, `sync`, close, and rename to the final path; +7. remove the exact temporary path on failure. + +Never overwrite a final YAML path. Return paths only after rename succeeds. + +- [ ] **Step 5: Run tests and commit** + +```bash +node --test tests/storage.test.mjs +npm run check +git add .gitignore src/storage tests/storage.test.mjs +git commit -m "Persist immutable Genesis YAML records" +``` + +--- + +### Task 5: Build the disposable SQLite projection and deterministic rebuild + +**Files:** +- Create: `src/storage/projection.mjs` +- Create: `tests/projection.test.mjs` + +**Interfaces:** +- `openProjection(dbPath) -> Database` +- `projectRecord(db, descriptor, record) -> void` +- `recordBlockedCommand(db, event) -> void` +- `readOpportunity(db, businessId) -> OpportunityProjection | null` +- `projectionConsistency(db, descriptors) -> { consistent, yamlCount, projectedCount }` +- `rebuildProjection({ projectRoot, registry }) -> { recordCount, businessCount }` + +- [ ] **Step 1: Write failing projection tests** + +Create two Decision versions, one evidence entry, and one draft Experiment in a temporary workspace. Assert projection tables expose the latest paths, state `approval_pending`, support/contradict counts, confidence, and timestamps. Delete `genesis.db`, rebuild, and assert the rebuilt rows deeply equal a normalized snapshot of the original rows. + +Also create malformed YAML and assert rebuild fails with `RECORD_SCHEMA_INVALID` while the prior database remains byte-for-byte unchanged. + +- [ ] **Step 2: Observe missing projection module** + +Run: `node --test tests/projection.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Create the projection schema** + +`openProjection` must enable foreign keys and create: + +```sql +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 +); +``` + +- [ ] **Step 4: Implement projection and rebuild** + +Wrap each projection update in a SQLite transaction. Upsert `record_versions`; derive the opportunity row only from canonical record content and descriptors. Rebuild into `.genesis/genesis.db.rebuild.tmp`, validate every YAML entry, verify source/projected counts, close both databases, then rename the temporary database over `genesis.db`. If any step fails, close and remove only the temporary database, preserving the prior database. + +- [ ] **Step 5: Run tests and commit** + +```bash +node --test tests/projection.test.mjs +npm run check +git add src/storage/projection.mjs tests/projection.test.mjs +git commit -m "Add rebuildable Genesis SQLite projection" +``` + +--- + +### Task 6: Evaluate Discover gates, preregistration completeness, and supported metrics + +**Files:** +- Create: `src/core/discovery-workflow.mjs` +- Create: `src/core/metrics.mjs` +- Create: `tests/discovery-workflow.test.mjs` + +**Interfaces:** +- `evaluateDiscoverGate({ decision, evidence }) -> { passed, blockers }` +- `experimentCompleteness(experiment) -> { complete, present, required, missing, ratio }` +- `buildStatus({ decisionVersions, experimentVersions, evidence, blockedCommands, consistency, now }) -> Status` +- `calculateMetrics(input) -> SupportedMetrics` + +- [ ] **Step 1: Write failing workflow tests** + +Assert that Discover is blocked separately for missing target customer, problem, hypothesis, and zero evidence. One confirmed evidence entry plus all required Decision fields passes. Assert that a complete draft experiment yields ratio `1`, state `approval_pending`, and next permitted command `status`; an incomplete draft lists exact JSON-pointer-like missing paths. + +For a fixed clock, assert metrics exactly equal: + +```js +{ + 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 +} +``` + +- [ ] **Step 2: Observe missing modules** + +Run: `node --test tests/discovery-workflow.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implement deterministic gates and metrics** + +Read preregistration requirements from `config/workflows/experiment-lifecycle.yaml`; do not duplicate a second normative list in application code. Treat trimmed empty strings, empty arrays, `null`, and `undefined` as missing. Calculate elapsed days as milliseconds divided by `86_400_000` without rounding. Do not produce realized value, experiment success, customer-reality ratio, or forecast calibration. + +- [ ] **Step 4: Run tests and commit** + +```bash +node --test tests/discovery-workflow.test.mjs +npm run check +git add src/core/discovery-workflow.mjs src/core/metrics.mjs tests/discovery-workflow.test.mjs +git commit -m "Evaluate discovery gates and early metrics" +``` + +--- + +### Task 7: Implement the human-confirmed application service + +**Files:** +- Create: `src/application/genesis-service.mjs` +- Create: `tests/genesis-service.test.mjs` + +**Interfaces:** +- `createGenesisService({ projectRoot, repoRoot, clock, confirm }) -> GenesisService` +- `service.startBusiness(input) -> CommandResult` +- `service.addEvidence(businessId, input) -> CommandResult` +- `service.status(businessId) -> Status` +- `service.planExperiment(businessId, input) -> CommandResult` +- `service.rebuildIndex() -> RebuildResult` + +- [ ] **Step 1: Write failing service tests** + +Inject `confirm: async proposal => true` and a fixed clock. Exercise: + +1. `startBusiness` writes evidence and Decision v1 and returns state `discover`; +2. `addEvidence` writes an Evidence entry and Decision v2 with v1 in `immutable_history_refs`; +3. `status` reports gates and counts; +4. `planExperiment` writes a draft Experiment and returns `approval_pending`; +5. repeating `startBusiness` throws `BUSINESS_ALREADY_EXISTS`; +6. declining confirmation returns `{ changed: false, reason: "cancelled" }` and writes nothing; +7. planning while a gate is missing throws `DISCOVER_GATE_BLOCKED` and records one blocked command; +8. requesting any transition beyond approval pending throws `COMMAND_UNAVAILABLE`. + +- [ ] **Step 2: Observe missing service** + +Run: `node --test tests/genesis-service.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implement YAML-first service operations** + +Each mutating method must run under `withWorkspaceLock` and follow: + +```js +const proposal = buildAndValidate(input); +if (!await confirm(proposal)) return { changed: false, reason: "cancelled" }; +const yaml = await writeRecord({ + projectRoot, + kind: proposal.record_type === "decision_record" ? "decision" : "experiment", + id: proposal.id, + version: nextVersion, + value: proposal, +}); +try { + projectRecord(db, yaml, proposal); + verifyProjectionReference(db, yaml.relativePath); + return { changed: true, record: proposal, path: yaml.relativePath, projection_stale: false }; +} catch (cause) { + return { changed: true, record: proposal, path: yaml.relativePath, projection_stale: true, + warning: new GenesisError("PROJECTION_STALE", "Canonical YAML is safe but SQLite is stale", { + correction: "Run genesis rebuild-index", escalation: "builder", cause, + }) }; +} +``` + +Never roll back or delete a successfully renamed YAML record because projection failed. Read current truth from YAML when SQLite is missing or marked inconsistent. + +- [ ] **Step 4: Run tests and commit** + +```bash +node --test tests/genesis-service.test.mjs +npm run check +git add src/application/genesis-service.mjs tests/genesis-service.test.mjs +git commit -m "Implement confirmed discovery workflow service" +``` + +--- + +### Task 8: Add the interactive CLI and resumable commands + +**Files:** +- Create: `src/cli/prompter.mjs` +- Create: `src/cli/render.mjs` +- Create: `src/cli/run-cli.mjs` +- Create: `tests/cli-integration.test.mjs` + +**Interfaces:** +- `createPrompter({ input, output }) -> { ask, choose, confirm, close }` +- `renderProposal(proposal) -> string` +- `renderStatus(status) -> string` +- `runCli(argv, dependencies?) -> Promise` + +- [ ] **Step 1: Write failing command-routing integration tests** + +Use temporary project roots and a scripted prompter rather than a real TTY. Assert exact exit codes: + +- `genesis start-business` returns 0 and prints the full proposal before the confirmation question; +- `genesis add-evidence bakery` returns 0 and labels support/contradict; +- `genesis status bakery` returns 0 and prints state, versions, evidence counts, gates, limits, next action, blockers, and projection consistency; +- `genesis plan-experiment bakery` returns 0 and prints `approval_pending`; +- `genesis rebuild-index` returns 0 and prints record/business counts; +- unknown command returns 2 and prints usage; +- policy or validation error returns 1 and prints code, path, correction, and escalation. + +- [ ] **Step 2: Observe missing CLI modules** + +Run: `node --test tests/cli-integration.test.mjs` + +Expected: FAIL with `ERR_MODULE_NOT_FOUND`. + +- [ ] **Step 3: Implement guided prompts and proposal rendering** + +`start-business` asks for business ID, target customer, problem, hypothesis, confidence, initial evidence, counterevidence, alternatives, expected outcome, metric, owner, and review date. `plan-experiment` asks for supported decision, baseline, comparison method, formula, population, denominator, data source, expected outcome, minimum meaningful effect, failure conditions, stop conditions, maximum cash, labor, duration, data classes, risk, owner, decision date, and allowed outcomes. + +Render suggestions under `Offline suggestion — not evidence:`. Render the complete proposed YAML-shaped object before one final `Save this immutable record? [y/N]` prompt. Default confirmation is no. + +- [ ] **Step 4: Implement routing without an execution command** + +Supported command grammar is exactly: + +```text +genesis start-business +genesis add-evidence +genesis status +genesis plan-experiment +genesis rebuild-index +``` + +Do not implement `run-experiment`, `approve`, `advance`, or a generic escape hatch. Resolve `repoRoot` from the installed module and default `projectRoot` to `process.cwd()`. + +- [ ] **Step 5: Run the binary and tests, then commit** + +```bash +node --test tests/cli-integration.test.mjs +node bin/genesis.mjs --help +npm run check +git add src/cli tests/cli-integration.test.mjs bin/genesis.mjs +git commit -m "Add interactive Genesis discovery commands" +``` + +Expected: help exits 0; tests and complete gate exit 0. + +--- + +### Task 9: Prove offline operation, recovery, locking, and fail-closed behavior + +**Files:** +- Create: `tests/no-network.test.mjs` +- Create: `tests/recovery.test.mjs` +- Modify: `tests/cli-integration.test.mjs` + +**Interfaces:** +- Uses only public interfaces from Tasks 4 through 8. + +- [ ] **Step 1: Write a failing no-network test** + +Before importing the application, replace network-capable globals with traps: + +```js +globalThis.fetch = async () => { throw new Error("NETWORK_USED"); }; +``` + +Run the complete scripted flow through `runCli`: start, add contradicting evidence, status, plan, delete SQLite, rebuild, status. Assert every command returns 0 and no output contains `NETWORK_USED`. + +- [ ] **Step 2: Add static network-import protection** + +Scan `bin/` and `src/` and fail if an import specifier matches `node:http`, `node:https`, `node:http2`, `node:net`, `undici`, `axios`, or `openai`, or if production code references `fetch(`. + +Run: `node --test tests/no-network.test.mjs` + +Expected before the complete harness exists: FAIL on the first unimplemented scripted dependency; after wiring, PASS with no network-capable import. + +- [ ] **Step 3: Write recovery and stale-projection tests** + +Inject a projection adapter that throws after YAML rename. Assert the command returns `changed: true`, the YAML exists and validates, the output contains `PROJECTION_STALE`, and `rebuild-index` restores consistency. Simulate a lock conflict and assert no YAML or SQLite change. Place a `.tmp` YAML file beside records and assert rebuild ignores it. + +- [ ] **Step 4: Run resilience tests and commit** + +```bash +node --test tests/no-network.test.mjs tests/recovery.test.mjs tests/cli-integration.test.mjs +npm run check +git add tests/no-network.test.mjs tests/recovery.test.mjs tests/cli-integration.test.mjs +git commit -m "Verify offline recovery and fail-closed CLI behavior" +``` + +--- + +### Task 10: Document operation and perform acceptance verification + +**Files:** +- Modify: `README.md` +- Modify: `Genesis Configuration.md` +- Modify: `.gitignore` +- Modify: `package.json` + +**Interfaces:** +- Documents the five supported CLI commands, YAML authority, SQLite rebuild, privacy boundary, approval stop, and troubleshooting codes. + +- [ ] **Step 1: Add documentation assertions** + +In `tests/configuration.test.mjs`, assert that `README.md` contains all five command strings, `.genesis/`, `approval_pending`, `YAML`, `SQLite`, and `rebuild-index`; assert `Genesis Configuration.md` labels the CLI runtime explanatory and links back to `genesis.yaml` as normative. + +- [ ] **Step 2: Run and observe missing documentation** + +Run: `node --test --test-name-pattern='CLI documentation' tests/configuration.test.mjs` + +Expected: FAIL until the operating documentation is added. + +- [ ] **Step 3: Write concise operator documentation** + +Document installation with `npm ci`, local invocation with `npm link` or `node bin/genesis.mjs`, one manual example flow, exact created paths, immutable version behavior, the approval boundary, supported metrics, privacy limitations, and recovery with `genesis rebuild-index`. State explicitly that the CLI does not research, contact customers, execute experiments, build products, deploy, bill, or operate a business. + +- [ ] **Step 4: Run a clean acceptance sequence** + +Run: + +```bash +npm ci +npm run check +node bin/genesis.mjs --help +git diff --check +git status --short +``` + +Expected: + +- dependency installation succeeds on Node 22; +- validation and all tests pass; +- help lists only the five approved commands; +- no whitespace errors appear; +- status contains only intentional implementation and documentation changes. + +- [ ] **Step 5: Review acceptance criteria manually** + +In a new temporary working directory, complete the five-command flow. Inspect the Decision v1/v2 YAML, evidence YAML, draft Experiment YAML, and SQLite rows. Delete only `genesis.db`, rebuild, and compare status output before and after. Confirm `plan-experiment` ends at `approval_pending` and there is no command capable of further progression. + +- [ ] **Step 6: Record consequential AI verification evidence** + +In the implementation handoff, record model identity, model version, material tool context, repository evidence sources, reviewer, exact verification commands, their exit status, actual cash/labor/duration consumption, and remaining uncertainty. Do not invent a reviewer or verification outcome. + +- [ ] **Step 7: Commit the completed release locally** + +```bash +git add README.md "Genesis Configuration.md" .gitignore package.json package-lock.json +git commit -m "Document Genesis discovery pipeline" +git status --short --branch +``` + +Expected: clean working tree on the implementation branch. Stop here unless a separate valid publication approval authorizes pushing this new work. + +--- + +## Completion Definition + +The implementation is complete only when a clean Node 22 environment can create and resume one opportunity, add supporting and contradicting evidence, show exact Discover blockers, create a schema-valid draft validation Experiment, stop at `approval_pending`, rebuild identical state and metrics from YAML after deleting SQLite, reject mismatched authority and sensitive data, operate with networking disabled, and pass `npm run check`. A passing document-only validation without the manual CLI acceptance flow is not completion. From ec69279ed3ab92b16929e2bab530034353922e6d Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Fri, 17 Jul 2026 23:59:20 +0400 Subject: [PATCH 03/18] Align canonical discovery record contracts --- config/workflows/experiment-lifecycle.yaml | 22 +-- ...val-cli-discovery-pipeline-2026-07-17.yaml | 53 ++++++ schemas/records/decision-record.schema.json | 5 +- schemas/records/experiment-record.schema.json | 31 +++- schemas/workflow.schema.json | 5 +- scripts/validate-genesis.mjs | 168 ++++++++++++++---- templates/decision-record.yaml | 1 + templates/experiment-record.yaml | 4 + tests/invariants.test.mjs | 168 ++++++++++++------ tests/records.test.mjs | 50 ++++++ 10 files changed, 402 insertions(+), 105 deletions(-) create mode 100644 records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml 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/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/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..b1b0988 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" }, @@ -27,14 +28,38 @@ }, "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" }, 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..dc4afca 100644 --- a/scripts/validate-genesis.mjs +++ b/scripts/validate-genesis.mjs @@ -470,15 +470,45 @@ 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 )); } +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 +528,22 @@ 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) => ( + 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 = 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 +564,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 +580,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 +604,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 +737,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 +755,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 +955,55 @@ function parseApprovalDate(value) { return Date.parse(value); } -export function validateApproval(record, { now, action, actor }) { +function approvalLimitsExceeded(approved, requested) { + if (requested === undefined) { + return false; + } + if (!requested || typeof requested !== "object" || Array.isArray(requested)) { + return true; + } + + const knownFields = new Set([ + "cash_usd", "labor_hours", "duration_days", "data_classes", "risk_level", + ]); + 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) => ( + typeof dataClass !== "string" || !approved.data_classes.includes(dataClass) + )) + )) { + return true; + } + + if (requested.risk_level !== undefined) { + const riskOrder = ["low", "medium", "high", "critical"]; + const requestedRisk = riskOrder.indexOf(requested.risk_level); + const approvedRisk = riskOrder.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) { @@ -967,6 +1061,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/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/invariants.test.mjs b/tests/invariants.test.mjs index 71d03db..b5266b2 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,37 +405,51 @@ 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: [{ ...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", }), [], ); @@ -440,9 +464,9 @@ 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", }), [], ); @@ -469,29 +493,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 +548,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 +604,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/records.test.mjs b/tests/records.test.mjs index 4815930..09ed9a5 100644 --- a/tests/records.test.mjs +++ b/tests/records.test.mjs @@ -133,6 +133,56 @@ test("approval scope and actor match exactly", () => { ); }); +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"] }, + { 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), + ); + } +}); + +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"); From 9bd4400cc6b9c7a3b404e204e752696c057b89cd Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 00:12:48 +0400 Subject: [PATCH 04/18] Harden canonical approval enforcement --- schemas/records/approval-record.schema.json | 3 +- schemas/records/experiment-record.schema.json | 3 +- scripts/validate-genesis.mjs | 95 ++++++++++++++++--- tests/invariants.test.mjs | 33 +++++++ tests/records.test.mjs | 56 +++++++++++ 5 files changed, 174 insertions(+), 16 deletions(-) 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/experiment-record.schema.json b/schemas/records/experiment-record.schema.json index b1b0988..a7c8576 100644 --- a/schemas/records/experiment-record.schema.json +++ b/schemas/records/experiment-record.schema.json @@ -24,7 +24,7 @@ "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/dataClasses" }, "risk_level": { "enum": ["low", "medium", "high", "critical"] } } }, "decision_date": { "$ref": "#/$defs/dateTime" }, "allowed_outcomes": { "const": ["scale", "pivot", "learning_lab", "archive", "kill"] }, @@ -67,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" } }, + "dataClasses": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/privacy" } }, "privacy": { "enum": ["public", "internal", "confidential", "restricted"] } } } diff --git a/scripts/validate-genesis.mjs b/scripts/validate-genesis.mjs index dc4afca..037cd45 100644 --- a/scripts/validate-genesis.mjs +++ b/scripts/validate-genesis.mjs @@ -477,6 +477,31 @@ function matchingApproval(approvals, { approverRole, action, actor, now, limits )); } +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); } @@ -537,13 +562,14 @@ export function validateTransition(policySet, workflowId, from, to, context = {} const prototype = context.learningPrototype; const now = Date.parse(context.now ?? new Date().toISOString()); const expiry = Date.parse(prototype?.expiresAt); - const validException = matchingApproval(context.approvals, { - approverRole: "human_authority", - action: "learning_prototype_exception", - actor: context.actor, - now: context.now, - limits: prototype?.limits, - }) + 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) @@ -955,7 +981,34 @@ function parseApprovalDate(value) { return Date.parse(value); } +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; } @@ -963,9 +1016,7 @@ function approvalLimitsExceeded(approved, requested) { return true; } - const knownFields = new Set([ - "cash_usd", "labor_hours", "duration_days", "data_classes", "risk_level", - ]); + const knownFields = new Set(APPROVAL_LIMIT_FIELDS); if (Object.keys(requested).some((field) => !knownFields.has(field))) { return true; } @@ -985,16 +1036,15 @@ function approvalLimitsExceeded(approved, requested) { !Array.isArray(requested.data_classes) || !Array.isArray(approved?.data_classes) || requested.data_classes.some((dataClass) => ( - typeof dataClass !== "string" || !approved.data_classes.includes(dataClass) + !DATA_CLASSES.has(dataClass) || !approved.data_classes.includes(dataClass) )) )) { return true; } if (requested.risk_level !== undefined) { - const riskOrder = ["low", "medium", "high", "critical"]; - const requestedRisk = riskOrder.indexOf(requested.risk_level); - const approvedRisk = riskOrder.indexOf(approved?.risk_level); + 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; } @@ -1014,6 +1064,23 @@ export function validateApproval(record, { now, action, actor, limits }) { )); } + 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), diff --git a/tests/invariants.test.mjs b/tests/invariants.test.mjs index b5266b2..42beaa2 100644 --- a/tests/invariants.test.mjs +++ b/tests/invariants.test.mjs @@ -453,6 +453,20 @@ test("Build requires passed validation or a bounded Human-approved learning prot }), [], ); + 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 () => { @@ -470,6 +484,25 @@ test("Build may transition to Launch only with Human Authority approval", async }), [], ); + + 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 () => { diff --git a/tests/records.test.mjs b/tests/records.test.mjs index 09ed9a5..53dda69 100644 --- a/tests/records.test.mjs +++ b/tests/records.test.mjs @@ -133,6 +133,44 @@ 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"); @@ -146,6 +184,7 @@ test("approval limits cannot exceed the canonical envelope", () => { { 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" }, ]; @@ -157,6 +196,23 @@ test("approval limits cannot exceed the canonical envelope", () => { 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 () => { From e443b7db30beb5fad7d408a4f933163f2ea232a9 Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 00:24:59 +0400 Subject: [PATCH 05/18] Add Genesis CLI runtime foundation --- bin/genesis.mjs | 3 + package-lock.json | 465 ++++++++++++++++++++++++++++++++++++++- package.json | 6 +- src/core/errors.mjs | 25 +++ src/core/ids.mjs | 31 +++ src/core/suggestions.mjs | 13 ++ tests/cli-core.test.mjs | 27 +++ 7 files changed, 561 insertions(+), 9 deletions(-) create mode 100755 bin/genesis.mjs create mode 100644 src/core/errors.mjs create mode 100644 src/core/ids.mjs create mode 100644 src/core/suggestions.mjs create mode 100644 tests/cli-core.test.mjs 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/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..cb9c5d4 100644 --- a/package.json +++ b/package.json @@ -6,14 +6,18 @@ "engines": { "node": ">=22" }, + "bin": { + "genesis": "./bin/genesis.mjs" + }, "scripts": { "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/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/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/tests/cli-core.test.mjs b/tests/cli-core.test.mjs new file mode 100644 index 0000000..7d6449f --- /dev/null +++ b/tests/cli-core.test.mjs @@ -0,0 +1,27 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +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); +}); From b91d095fde1af55e2e55501923263ec410ff2b16 Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 00:35:45 +0400 Subject: [PATCH 06/18] Build schema-valid discovery records --- schemas/runtime/evidence-entry.schema.json | 38 ++++++ src/core/record-builders.mjs | 142 ++++++++++++++++++++ src/core/schema-registry.mjs | 77 +++++++++++ tests/record-builders.test.mjs | 147 +++++++++++++++++++++ 4 files changed, 404 insertions(+) create mode 100644 schemas/runtime/evidence-entry.schema.json create mode 100644 src/core/record-builders.mjs create mode 100644 src/core/schema-registry.mjs create mode 100644 tests/record-builders.test.mjs 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/src/core/record-builders.mjs b/src/core/record-builders.mjs new file mode 100644 index 0000000..e39674f --- /dev/null +++ b/src/core/record-builders.mjs @@ -0,0 +1,142 @@ +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"; +} + +export function buildEvidenceEntry(input, clock) { + 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 registry.validateEvidence(evidence); +} + +export function buildDecisionRecord(input, clock) { + 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 registry.validateRecord("decision_record", decision); +} + +export function versionDecisionRecord(previous, changes, historyRef, clock) { + 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 registry.validateRecord("decision_record", decision); +} + +export function buildExperimentRecord(input, clock) { + const businessId = normalizeBusinessId(input.business_id); + const id = `${businessId}-experiment`; + const now = timestamp(clock); + 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 registry.validateRecord("experiment_record", experiment); +} diff --git a/src/core/schema-registry.mjs b/src/core/schema-registry.mjs new file mode 100644 index 0000000..56564dc --- /dev/null +++ b/src/core/schema-registry.mjs @@ -0,0 +1,77 @@ +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); + if (resolved !== repoRoot && !resolved.startsWith(`${repoRoot}${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 ?? []; + return new GenesisError("RECORD_SCHEMA_INVALID", "Record failed its registered schema", { + path: ajvErrors[0]?.instancePath ?? "", + correction: ajvErrors.map((error) => error.message ?? error.keyword).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/tests/record-builders.test.mjs b/tests/record-builders.test.mjs new file mode 100644 index 0000000..858cd3c --- /dev/null +++ b/tests/record-builders.test.mjs @@ -0,0 +1,147 @@ +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", () => { + 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", + ); +}); From d1b333643ccbde9478bd4825ec93bd922b805e92 Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 00:49:23 +0400 Subject: [PATCH 07/18] Harden experiment data gating --- src/core/record-builders.mjs | 43 +++++++++++++++++++++++------ src/core/schema-registry.mjs | 14 +++++++--- tests/record-builders.test.mjs | 49 ++++++++++++++++++++++++++++++++++ 3 files changed, 95 insertions(+), 11 deletions(-) diff --git a/src/core/record-builders.mjs b/src/core/record-builders.mjs index e39674f..c3e690d 100644 --- a/src/core/record-builders.mjs +++ b/src/core/record-builders.mjs @@ -17,7 +17,30 @@ function privacy(input) { return input.privacy_classification ?? "internal"; } -export function buildEvidenceEntry(input, clock) { +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", @@ -37,10 +60,11 @@ export function buildEvidenceEntry(input, clock) { privacy_classification: privacy(input), }; - return registry.validateEvidence(evidence); + return activeRegistry.validateEvidence(evidence); } -export function buildDecisionRecord(input, clock) { +export function buildDecisionRecord(input, clock, options = {}) { + const activeRegistry = resolveRegistry(options); const businessId = normalizeBusinessId(input.business_id); const id = `${businessId}-decision`; const now = timestamp(clock); @@ -75,10 +99,11 @@ export function buildDecisionRecord(input, clock) { confidence_update: null, }; - return registry.validateRecord("decision_record", decision); + return activeRegistry.validateRecord("decision_record", decision); } -export function versionDecisionRecord(previous, changes, historyRef, clock) { +export function versionDecisionRecord(previous, changes, historyRef, clock, options = {}) { + const activeRegistry = resolveRegistry(options); const decision = { ...previous, ...changes, @@ -95,13 +120,15 @@ export function versionDecisionRecord(previous, changes, historyRef, clock) { ])], }; - return registry.validateRecord("decision_record", decision); + return activeRegistry.validateRecord("decision_record", decision); } -export function buildExperimentRecord(input, clock) { +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", @@ -138,5 +165,5 @@ export function buildExperimentRecord(input, clock) { approval_references: [], }; - return registry.validateRecord("experiment_record", experiment); + return activeRegistry.validateRecord("experiment_record", experiment); } diff --git a/src/core/schema-registry.mjs b/src/core/schema-registry.mjs index 56564dc..0259c4e 100644 --- a/src/core/schema-registry.mjs +++ b/src/core/schema-registry.mjs @@ -9,7 +9,9 @@ import { GenesisError } from "./errors.mjs"; function resolveInsideRepo(repoRoot, relativePath) { const resolved = path.resolve(repoRoot, relativePath); - if (resolved !== repoRoot && !resolved.startsWith(`${repoRoot}${path.sep}`)) { + 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}`, @@ -33,9 +35,15 @@ function readManifest(manifestPath) { function validationError(errors) { const ajvErrors = errors ?? []; + const first = ajvErrors[0]; return new GenesisError("RECORD_SCHEMA_INVALID", "Record failed its registered schema", { - path: ajvErrors[0]?.instancePath ?? "", - correction: ajvErrors.map((error) => error.message ?? error.keyword).join("; "), + 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", }); } diff --git a/tests/record-builders.test.mjs b/tests/record-builders.test.mjs index 858cd3c..b829d0b 100644 --- a/tests/record-builders.test.mjs +++ b/tests/record-builders.test.mjs @@ -144,4 +144,53 @@ test("builders reject restricted evidence and schema-invalid records", () => { && 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"), + ); +}); + +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"); }); From b140b32eec23ae37cfd8174bd8de33e4a58eb3da Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 00:57:41 +0400 Subject: [PATCH 08/18] Persist immutable Genesis YAML records --- .gitignore | 1 + .superpowers/sdd/progress.md | 10 ++ .superpowers/sdd/task-4-report.md | 16 +++ src/storage/workspace.mjs | 74 +++++++++++++ src/storage/yaml-record-store.mjs | 145 +++++++++++++++++++++++++ tests/storage.test.mjs | 172 ++++++++++++++++++++++++++++++ 6 files changed, 418 insertions(+) create mode 100644 .superpowers/sdd/progress.md create mode 100644 .superpowers/sdd/task-4-report.md create mode 100644 src/storage/workspace.mjs create mode 100644 src/storage/yaml-record-store.mjs create mode 100644 tests/storage.test.mjs diff --git a/.gitignore b/.gitignore index c1a7afd..f137edb 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ node_modules/ npm-debug.log* *.tmp +.genesis/ diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md new file mode 100644 index 0000000..c2bea6f --- /dev/null +++ b/.superpowers/sdd/progress.md @@ -0,0 +1,10 @@ +# Genesis CLI Discovery Pipeline SDD Progress + +Plan: docs/superpowers/plans/2026-07-17-genesis-cli-discovery-pipeline.md +Branch base: 5daa7fc +Approval: records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml (validated 2026-07-17) + +Task 1: complete (commits 5daa7fc..9bd4400, review clean) +Task 2: complete (commits 9bd4400..e443b7d, review approved; minor optional branch coverage noted for final review) +Task 3: complete (commits e443b7d..b91d095, review approved after fix) +Task 4: complete (storage layer and tests passed) diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md new file mode 100644 index 0000000..83f3574 --- /dev/null +++ b/.superpowers/sdd/task-4-report.md @@ -0,0 +1,16 @@ +# Task 4 Report + +Implemented the private workspace and append-only YAML storage layer. + +Changed: +- Added `.genesis/` to `.gitignore`. +- Added `src/storage/workspace.mjs` with `workspacePaths`, `ensureWorkspace`, and `withWorkspaceLock`. +- Added `src/storage/yaml-record-store.mjs` with `writeRecord`, `readRecord`, and `listRecords`. +- Added `tests/storage.test.mjs` covering private directory creation, append-only writes, duplicate version rejection, workspace locking, invalid kinds, and sorted record listing. + +Verification: +- `node --test tests/storage.test.mjs` +- `npm run check` + +Concerns: +- None after the listing kind-name fix. diff --git a/src/storage/workspace.mjs b/src/storage/workspace.mjs new file mode 100644 index 0000000..8dbdd47 --- /dev/null +++ b/src/storage/workspace.mjs @@ -0,0 +1,74 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; + +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, "workspace.sqlite"), + 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; +} + +export async function withWorkspaceLock(projectRoot, operation) { + const paths = ensureWorkspace(projectRoot); + let handle; + let operationError; + + try { + handle = await fsp.open(paths.lock, "wx", 0o600); + } catch (error) { + if (error?.code === "EEXIST") { + throw new GenesisError("WORKSPACE_LOCKED", "The Genesis workspace is already locked", { + path: "/workspace/lock", + correction: "Wait for the current workspace operation to finish before retrying", + escalation: "builder", + }); + } + throw error; + } + + try { + await handle.writeFile(`${process.pid}\n${new Date().toISOString()}\n`); + await handle.sync(); + return await operation(); + } catch (error) { + operationError = error; + throw error; + } finally { + if (handle) { + await handle.close().catch(() => {}); + } + + try { + await fsp.unlink(paths.lock); + } catch (error) { + if (!operationError && error?.code !== "ENOENT") { + throw error; + } + } + } +} diff --git a/src/storage/yaml-record-store.mjs b/src/storage/yaml-record-store.mjs new file mode 100644 index 0000000..cee5f66 --- /dev/null +++ b/src/storage/yaml-record-store.mjs @@ -0,0 +1,145 @@ +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 writeAtomicYaml(finalPath, value) { + const directory = path.dirname(finalPath); + const content = `${YAML.stringify(value)}\n`; + const tempPath = path.join( + directory, + `.${path.basename(finalPath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`, + ); + + let handle; + try { + handle = await fsp.open(tempPath, "wx", 0o600); + await handle.writeFile(content); + await handle.sync(); + await handle.close(); + handle = null; + await fsp.rename(tempPath, finalPath); + } catch (error) { + if (handle) { + await handle.close().catch(() => {}); + } + await fsp.unlink(tempPath).catch(() => {}); + throw error; + } +} + +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 finalPath = recordPath(projectRoot, kind, id, version); + ensureWorkspace(projectRoot); + const directory = path.dirname(finalPath); + fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); + fs.chmodSync(directory, 0o700); + + if (fs.existsSync(finalPath)) { + throw new GenesisError("RECORD_VERSION_EXISTS", "Record version already exists", { + path: finalPath, + correction: "Use the next version number for the record instead of overwriting an existing file", + escalation: "builder", + }); + } + + await writeAtomicYaml(finalPath, value); + return { + absolutePath: finalPath, + relativePath: path.relative(projectRoot, 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/tests/storage.test.mjs b/tests/storage.test.mjs new file mode 100644 index 0000000..3a6f22f --- /dev/null +++ b/tests/storage.test.mjs @@ -0,0 +1,172 @@ +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, + writeRecord, +} 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("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("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); + } +}); From 4352e8c6c18f07aff7f665c295017f20cbfc826c Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 01:27:50 +0400 Subject: [PATCH 09/18] Add rebuildable Genesis SQLite projection --- .superpowers/sdd/progress.md | 1 + .superpowers/sdd/task-5-report.md | 19 ++ src/storage/projection.mjs | 355 ++++++++++++++++++++++++++++++ src/storage/workspace.mjs | 2 +- tests/projection.test.mjs | 282 ++++++++++++++++++++++++ 5 files changed, 658 insertions(+), 1 deletion(-) create mode 100644 .superpowers/sdd/task-5-report.md create mode 100644 src/storage/projection.mjs create mode 100644 tests/projection.test.mjs diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index c2bea6f..05b593c 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -8,3 +8,4 @@ Task 1: complete (commits 5daa7fc..9bd4400, review clean) Task 2: complete (commits 9bd4400..e443b7d, review approved; minor optional branch coverage noted for final review) Task 3: complete (commits e443b7d..b91d095, review approved after fix) Task 4: complete (storage layer and tests passed) +Task 5: complete (rebuildable SQLite projection and tests passed) diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md new file mode 100644 index 0000000..3d85b78 --- /dev/null +++ b/.superpowers/sdd/task-5-report.md @@ -0,0 +1,19 @@ +# Task 5 Report: SQLite projection and deterministic rebuild + +Implemented `src/storage/projection.mjs` and `tests/projection.test.mjs`. + +Changes: +- Added `openProjection(dbPath)` to create the projection schema with foreign keys enabled. +- Added `projectRecord(db, descriptor, record)` with SQLite transaction semantics for decision, experiment, and evidence projections. +- Added `recordBlockedCommand(db, event)` and `readOpportunity(db, businessId)`. +- Added `projectionConsistency(db, descriptors)` for source/projection count checks. +- Added `rebuildProjection({ projectRoot, registry })` to rebuild into `.genesis/genesis.db.rebuild.tmp`, validate YAML, ignore `.tmp` YAML files, preserve the prior database on failure, and atomically replace `genesis.db` on success. +- Added focused tests covering latest-path projection, counts, snapshot equality after rebuild, `.tmp` file filtering, blocked command recording, and fail-closed behavior when YAML is malformed. + +Tests run: +- `node --test tests/projection.test.mjs` +- `npm run check` +- `git diff --check` + +Concerns: +- `projectionConsistency` is intentionally conservative and only checks the local projection state and registered record types. If the record model expands, the descriptor registry and projection rules should be updated together. diff --git a/src/storage/projection.mjs b/src/storage/projection.mjs new file mode 100644 index 0000000..2f62e9f --- /dev/null +++ b/src/storage/projection.mjs @@ -0,0 +1,355 @@ +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) { + db.prepare(` + INSERT INTO record_versions (record_type, record_id, version, relative_path, updated_at) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(record_type, record_id, version) + DO UPDATE SET + relative_path = excluded.relative_path, + updated_at = excluded.updated_at + `).run( + descriptor.kind, + descriptor.id, + descriptor.version, + descriptor.relativePath, + record.updated_at ?? record.collected_at ?? record.created_at ?? new Date().toISOString(), + ); +} + +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(() => { + upsertRecordVersion(db, descriptor, record); + + if (descriptor.kind === "decision") { + projectDecision(db, descriptor, record); + return; + } + + if (descriptor.kind === "evidence") { + 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 projectedCount = db.prepare("SELECT COUNT(*) AS count FROM record_versions").get().count; + return { + consistent: yamlCount === projectedCount, + 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 index 8dbdd47..f351b90 100644 --- a/src/storage/workspace.mjs +++ b/src/storage/workspace.mjs @@ -13,7 +13,7 @@ export function workspacePaths(projectRoot) { decisions: path.join(records, "decisions"), experiments: path.join(records, "experiments"), evidence: path.join(records, "evidence"), - db: path.join(root, "workspace.sqlite"), + db: path.join(root, "genesis.db"), lock: path.join(root, "workspace.lock"), }; } diff --git a/tests/projection.test.mjs b/tests/projection.test.mjs new file mode 100644 index 0000000..356c95e --- /dev/null +++ b/tests/projection.test.mjs @@ -0,0 +1,282 @@ +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("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, + }); + + 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); + } +}); From f873441f2f79231b342faa65f2283eed61925e76 Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 03:16:43 +0400 Subject: [PATCH 10/18] Evaluate discovery gates and early metrics --- .superpowers/sdd/progress.md | 2 + .superpowers/sdd/task-6-report.md | 36 ++++ src/core/discovery-workflow.mjs | 182 +++++++++++++++++ src/core/metrics.mjs | 143 ++++++++++++++ tests/discovery-workflow.test.mjs | 318 ++++++++++++++++++++++++++++++ 5 files changed, 681 insertions(+) create mode 100644 .superpowers/sdd/task-6-report.md create mode 100644 src/core/discovery-workflow.mjs create mode 100644 src/core/metrics.mjs create mode 100644 tests/discovery-workflow.test.mjs diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index 05b593c..fcb1f3d 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -9,3 +9,5 @@ Task 2: complete (commits 9bd4400..e443b7d, review approved; minor optional bran Task 3: complete (commits e443b7d..b91d095, review approved after fix) Task 4: complete (storage layer and tests passed) Task 5: complete (rebuildable SQLite projection and tests passed) +Task 6: complete (discovery gate, preregistration completeness, and supported metrics passed) +Task 6: complete (discover gates and supported metrics passed) diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md new file mode 100644 index 0000000..ad0f157 --- /dev/null +++ b/.superpowers/sdd/task-6-report.md @@ -0,0 +1,36 @@ +# Task 6 Report + +Implemented the discovery workflow layer for Genesis CLI gating and early metrics. + +Changes made: + +- Added `src/core/discovery-workflow.mjs` with: + - `evaluateDiscoverGate({ decision, evidence })` + - `experimentCompleteness(experiment)` + - `buildStatus({ decisionVersions, experimentVersions, evidence, blockedCommands, consistency, now })` +- Added `src/core/metrics.mjs` with: + - `calculateMetrics(input)` + - workflow-derived preregistration field loading from `config/workflows/experiment-lifecycle.yaml` +- Added `tests/discovery-workflow.test.mjs` covering: + - isolated Discover gate blocks + - full gate pass + - preregistration completeness loading from YAML + - exact missing-path reporting + - complete draft status behavior + - supported metrics output + +Verification run: + +- `node --test tests/discovery-workflow.test.mjs` +- `npm run check` + +Notes: + +- Metrics are limited to the supported fields in the brief. +- Elapsed-day calculations use millisecond differences without rounding. +- The completeness logic reads preregistration requirements from the workflow YAML rather than duplicating the normative list in application code. + +Concerns: + +- No remaining functional concerns from Task 6 verification. +- Later CLI tasks will need to consume these helpers consistently for status rendering and command routing. diff --git a/src/core/discovery-workflow.mjs b/src/core/discovery-workflow.mjs new file mode 100644 index 0000000..3be69d9 --- /dev/null +++ b/src/core/discovery-workflow.mjs @@ -0,0 +1,182 @@ +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 + ? (experiment?.status === "draft" && experimentIsComplete ? "approval_pending" : "discover") + : "discover"; + const nextCommand = hasExperiment + ? (experimentIsComplete ? "status" : "plan-experiment") + : (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/metrics.mjs b/src/core/metrics.mjs new file mode 100644 index 0000000..d363831 --- /dev/null +++ b/src/core/metrics.mjs @@ -0,0 +1,143 @@ +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")); +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/tests/discovery-workflow.test.mjs b/tests/discovery-workflow.test.mjs new file mode 100644 index 0000000..b54f8f1 --- /dev/null +++ b/tests/discovery-workflow.test.mjs @@ -0,0 +1,318 @@ +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, "plan-experiment"); + 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); +}); From f1eb2de8dcdd5b152126689483fa5d7b3801dfc5 Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 03:52:36 +0400 Subject: [PATCH 11/18] Implement confirmed discovery workflow service --- .superpowers/sdd/progress.md | 2 +- .superpowers/sdd/task-7-report.md | 39 +++ src/application/genesis-service.mjs | 513 ++++++++++++++++++++++++++++ tests/genesis-service.test.mjs | 301 ++++++++++++++++ 4 files changed, 854 insertions(+), 1 deletion(-) create mode 100644 .superpowers/sdd/task-7-report.md create mode 100644 src/application/genesis-service.mjs create mode 100644 tests/genesis-service.test.mjs diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index fcb1f3d..1dc2232 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -10,4 +10,4 @@ Task 3: complete (commits e443b7d..b91d095, review approved after fix) Task 4: complete (storage layer and tests passed) Task 5: complete (rebuildable SQLite projection and tests passed) Task 6: complete (discovery gate, preregistration completeness, and supported metrics passed) -Task 6: complete (discover gates and supported metrics passed) +Task 7: complete (human-confirmed application service passed) diff --git a/.superpowers/sdd/task-7-report.md b/.superpowers/sdd/task-7-report.md new file mode 100644 index 0000000..9964f98 --- /dev/null +++ b/.superpowers/sdd/task-7-report.md @@ -0,0 +1,39 @@ +# Task 7 Report + +Implemented the human-confirmed Genesis application service that writes immutable YAML records, updates the rebuildable SQLite projection, and stops at the approval boundary. + +Changes made: + +- Added `src/application/genesis-service.mjs` with: + - `createGenesisService({ projectRoot, repoRoot, clock, confirm })` + - `startBusiness(input)` + - `addEvidence(businessId, input)` + - `status(businessId)` + - `planExperiment(businessId, input)` + - `rebuildIndex()` +- The service now: + - writes Decision and Evidence YAML under a workspace lock + - versions Decision history immutably + - computes status from canonical YAML and supported metrics + - records blocked commands when Discover is incomplete + - returns `COMMAND_UNAVAILABLE` once approval pending already exists + - falls back to `PROJECTION_STALE` if YAML is safe but SQLite projection fails +- Added `tests/genesis-service.test.mjs` covering: + - start-business creation + - add-evidence versioning + - draft experiment planning and approval-pending stop + - repeated start rejection + - confirmation cancellation + - blocked Discover planning + - command unavailability after approval pending + - rebuild-index recovery + +Verification run: + +- `node --test tests/genesis-service.test.mjs` + +Notes: + +- The service keeps YAML as the durable source of truth and treats SQLite as a rebuildable projection. +- Status output is derived from canonical records rather than ad hoc state. +- The service is ready for the later CLI layer to consume directly. diff --git a/src/application/genesis-service.mjs b/src/application/genesis-service.mjs new file mode 100644 index 0000000..d84a419 --- /dev/null +++ b/src/application/genesis-service.mjs @@ -0,0 +1,513 @@ +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, writeRecord } 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, entries.map(({ descriptor }) => descriptor)); + 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: 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 }) { + const written = []; + const items = proposal.records ?? (proposal.record ? [ + { + kind: proposal.record.record_type === "decision_record" ? "decision" : "experiment", + record: proposal.record, + version: 1, + }, + ] : []); + + for (const item of items) { + const saved = await writeRecord({ + projectRoot, + kind: item.kind, + id: item.record.id, + version: item.version, + value: item.record, + }); + written.push({ + ...item, + ...saved, + }); + } + + return { + items, + written, + ...projectWrittenRecords({ projectRoot, registry, written }), + }; +} + +export function createGenesisService({ + projectRoot, + repoRoot, + clock = DEFAULT_CLOCK, + confirm = DEFAULT_CONFIRM, + registry, +} = {}) { + 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, + }); + + 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/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); + } +}); From d309881750fb6da35145e70884bf6d9d35f73620 Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 04:38:13 +0400 Subject: [PATCH 12/18] Add interactive Genesis discovery commands --- .superpowers/sdd/progress.md | 1 + .superpowers/sdd/task-8-report.md | 40 +++++ src/cli/prompter.mjs | 55 ++++++ src/cli/render.mjs | 73 ++++++++ src/cli/run-cli.mjs | 287 ++++++++++++++++++++++++++++++ tests/cli-integration.test.mjs | 246 +++++++++++++++++++++++++ 6 files changed, 702 insertions(+) create mode 100644 .superpowers/sdd/task-8-report.md create mode 100644 src/cli/prompter.mjs create mode 100644 src/cli/render.mjs create mode 100644 src/cli/run-cli.mjs create mode 100644 tests/cli-integration.test.mjs diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md index 1dc2232..f7981ac 100644 --- a/.superpowers/sdd/progress.md +++ b/.superpowers/sdd/progress.md @@ -11,3 +11,4 @@ Task 4: complete (storage layer and tests passed) Task 5: complete (rebuildable SQLite projection and tests passed) Task 6: complete (discovery gate, preregistration completeness, and supported metrics passed) Task 7: complete (human-confirmed application service passed) +Task 8: complete (interactive CLI and resumable commands passed) diff --git a/.superpowers/sdd/task-8-report.md b/.superpowers/sdd/task-8-report.md new file mode 100644 index 0000000..bc07673 --- /dev/null +++ b/.superpowers/sdd/task-8-report.md @@ -0,0 +1,40 @@ +# Task 8 Report + +Implemented the interactive Genesis CLI layer for the approved discovery workflow. + +Changes made: + +- Added `src/cli/prompter.mjs` with scripted and terminal-friendly prompt handling. +- Added `src/cli/render.mjs` with proposal, status, rebuild-result, and error rendering. +- Added `src/cli/run-cli.mjs` with routing for: + - `genesis start-business` + - `genesis add-evidence ` + - `genesis status ` + - `genesis plan-experiment ` + - `genesis rebuild-index` +- The CLI: + - prints proposal YAML before confirmation + - stays offline + - uses the confirmed application service + - returns `2` for unknown commands + - returns `1` for Genesis errors + - returns `0` for successful commands +- Added `tests/cli-integration.test.mjs` covering: + - start-business flow + - add-evidence flow + - status rendering + - plan-experiment flow + - rebuild-index + - unknown commands + - fail-closed validation errors + +Verification run: + +- `node --test tests/cli-integration.test.mjs` +- `node bin/genesis.mjs --help` +- `npm run check` + +Notes: + +- The CLI uses a scripted prompter in tests and a real readline prompter in normal use. +- Proposal rendering happens before confirmation so the user sees the full immutable record before saving. diff --git a/src/cli/prompter.mjs b/src/cli/prompter.mjs new file mode 100644 index 0000000..cac9ab2 --- /dev/null +++ b/src/cli/prompter.mjs @@ -0,0 +1,55 @@ +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"); + const answer = (await ask(`${question}\n${lines}\n> `)).trim(); + if (!answer) { + return normalized[0]?.value; + } + + const numeric = Number.parseInt(answer, 10); + 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 + )); + return direct?.value ?? normalized[0]?.value; + } + + 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..cf65e5f --- /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.parseFloat(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/tests/cli-integration.test.mjs b/tests/cli-integration.test.mjs new file mode 100644 index 0000000..b450db0 --- /dev/null +++ b/tests/cli-integration.test.mjs @@ -0,0 +1,246 @@ +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"; + +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); + + const rebuildExit = await runCli(["rebuild-index"], { + projectRoot, + repoRoot: ROOT, + clock: CLOCK, + prompter: scriptedPrompter, + output, + errorOutput: output, + }); + assert.equal(rebuildExit, 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); + } 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); + } +}); From 41d7c5015ab1d54014b0257741d713b23e5fc3ee Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 04:41:36 +0400 Subject: [PATCH 13/18] Accept supported decision in experiment planning --- src/application/genesis-service.mjs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/application/genesis-service.mjs b/src/application/genesis-service.mjs index d84a419..e8465a9 100644 --- a/src/application/genesis-service.mjs +++ b/src/application/genesis-service.mjs @@ -346,7 +346,7 @@ function planExperimentProposal(projectRoot, businessId, input, clock, registry) evidence_references: evidenceReferences, related_records: relatedRecords, problem: input.problem ?? latestDecision.record.problem, - supported_decision: latestDecision.record.id, + 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), From 3beee47f0d9a90df4aab1abbb0de2cf5270356c7 Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 16:36:15 +0400 Subject: [PATCH 14/18] Verify offline recovery and fail-closed CLI behavior --- src/application/genesis-service.mjs | 23 ++- tests/cli-integration.test.mjs | 14 ++ tests/no-network.test.mjs | 270 ++++++++++++++++++++++++++++ tests/recovery.test.mjs | 249 +++++++++++++++++++++++++ 4 files changed, 550 insertions(+), 6 deletions(-) create mode 100644 tests/no-network.test.mjs create mode 100644 tests/recovery.test.mjs diff --git a/src/application/genesis-service.mjs b/src/application/genesis-service.mjs index e8465a9..bc8815f 100644 --- a/src/application/genesis-service.mjs +++ b/src/application/genesis-service.mjs @@ -375,7 +375,7 @@ function planExperimentProposal(projectRoot, businessId, input, clock, registry) }; } -async function persistCommand({ projectRoot, registry, proposal }) { +async function persistCommand({ projectRoot, registry, proposal, projectRecords = projectWrittenRecords }) { const written = []; const items = proposal.records ?? (proposal.record ? [ { @@ -399,11 +399,20 @@ async function persistCommand({ projectRoot, registry, proposal }) { }); } - return { - items, - written, - ...projectWrittenRecords({ projectRoot, registry, written }), - }; + try { + return { + items, + written, + ...await projectRecords({ projectRoot, registry, written }), + }; + } catch (cause) { + return { + items, + written, + projection_stale: true, + warning: projectionIssue(cause), + }; + } } export function createGenesisService({ @@ -412,6 +421,7 @@ export function createGenesisService({ clock = DEFAULT_CLOCK, confirm = DEFAULT_CONFIRM, registry, + projectRecords = projectWrittenRecords, } = {}) { if (!projectRoot) { throw new GenesisError("PROJECT_ROOT_REQUIRED", "A project root is required", { @@ -434,6 +444,7 @@ export function createGenesisService({ projectRoot, registry: activeRegistry, proposal, + projectRecords, }); const status = currentStatus({ diff --git a/tests/cli-integration.test.mjs b/tests/cli-integration.test.mjs index b450db0..fcceff4 100644 --- a/tests/cli-integration.test.mjs +++ b/tests/cli-integration.test.mjs @@ -5,6 +5,7 @@ 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"); @@ -179,6 +180,8 @@ test("CLI runs start-business, add-evidence, status, plan-experiment, and rebuil }); assert.equal(planExit, 0); + fs.rmSync(workspacePaths(projectRoot).db, { force: true }); + const rebuildExit = await runCli(["rebuild-index"], { projectRoot, repoRoot: ROOT, @@ -189,6 +192,16 @@ test("CLI runs start-business, add-evidence, status, plan-experiment, and rebuil }); 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); @@ -204,6 +217,7 @@ test("CLI runs start-business, add-evidence, status, plan-experiment, and rebuil 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); } diff --git a/tests/no-network.test.mjs b/tests/no-network.test.mjs new file mode 100644 index 0000000..835c863 --- /dev/null +++ b/tests/no-network.test.mjs @@ -0,0 +1,270 @@ +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 { workspacePaths } from "../src/storage/workspace.mjs"; + +const ROOT = path.resolve(import.meta.dirname, ".."); +const FORBIDDEN_IMPORTS = [ + "node:http", + "node:https", + "node:http2", + "node:net", + "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; +} + +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 text = fs.readFileSync(filePath, "utf8"); + for (const specifier of FORBIDDEN_IMPORTS) { + const importPattern = new RegExp(String.raw`(?:from|import\()\s*['"]${specifier.replaceAll("/", "\\/")}['"]`, "u"); + if (importPattern.test(text)) { + violations.push(`${path.relative(ROOT, filePath)} imports ${specifier}`); + } + } + + if (/\bfetch\s*\(/u.test(text)) { + 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; + 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; + cleanupProjectRoot(projectRoot); + } +}); 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); + } +}); From 638eb8b812d709fa1843832c44831d486e7b08d4 Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 16:40:45 +0400 Subject: [PATCH 15/18] Document Genesis discovery pipeline --- .gitignore | 1 + Genesis Configuration.md | 2 +- README.md | 88 ++++++++++++++++++++++++++++++++++++ package.json | 1 + tests/configuration.test.mjs | 31 +++++++++++++ 5 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 README.md diff --git a/.gitignore b/.gitignore index f137edb..ebf51ed 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ 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..91a7605 --- /dev/null +++ b/README.md @@ -0,0 +1,88 @@ +# Genesis 2.0 + +Genesis is an offline CLI for recording and reviewing discovery work. It creates immutable YAML records under `.genesis/` and rebuilds a SQLite projection from those records when needed. + +Policy-Version: 2.0.0 +Authority: Explanatory + +## Install + +```bash +npm ci +``` + +## Run locally + +Use whichever entry point fits your workflow: + +```bash +npm start +``` + +or + +```bash +node bin/genesis.mjs --help +``` + +If you want the command on your PATH during local development, you can also use: + +```bash +npm link +genesis --help +``` + +## Supported commands + +- `genesis start-business` +- `genesis add-evidence ` +- `genesis status ` +- `genesis plan-experiment ` +- `genesis rebuild-index` + +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. + +## What it creates + +Each workspace gets these paths: + +- `.genesis/records/decisions/*.yaml` +- `.genesis/records/evidence/*.yaml` +- `.genesis/records/experiments/*.yaml` +- `.genesis/genesis.db` +- `.genesis/workspace.lock` + +Records are immutable versioned YAML files. If the SQLite projection becomes stale, the canonical YAML is still the source of truth and you recover with: + +```bash +genesis rebuild-index +``` + +## Manual flow + +1. Start a business opportunity. +2. Add supporting or contradicting evidence. +3. Check `status` to see counts, gate state, and projection consistency. +4. Plan an experiment when the Discover gate is satisfied. +5. If the database is removed or stale, run `genesis rebuild-index` and re-check `status`. + +Example: + +```bash +genesis start-business +genesis add-evidence bakery +genesis status bakery +genesis plan-experiment bakery +genesis rebuild-index +``` + +## Privacy and scope + +- Use public, internal, or confidential classifications exactly as prompted. +- Do not store secrets or sensitive personal data in records. +- Do not treat prompts, suggestions, or examples as evidence or approval. +- Human Authority approval is required for protected actions and cannot be inferred from silence or previous behavior. + +## Documentation boundary + +`Genesis Configuration.md` is explanatory only. The normative policy is `genesis.yaml` and its referenced YAML files. diff --git a/package.json b/package.json index cb9c5d4..7580e93 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,7 @@ "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" 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"); +}); From 3eafccd37077df3242c8372384a54dc96d3bdad7 Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 19:16:07 +0400 Subject: [PATCH 16/18] Remove agent planning artifacts --- .superpowers/sdd/progress.md | 14 - .superpowers/sdd/task-4-report.md | 16 - .superpowers/sdd/task-5-report.md | 19 - .superpowers/sdd/task-6-report.md | 36 - .superpowers/sdd/task-7-report.md | 39 - .superpowers/sdd/task-8-report.md | 40 - ...26-07-17-genesis-cli-discovery-pipeline.md | 884 ---------------- .../2026-07-17-genesis-operating-system.md | 990 ------------------ ...7-genesis-cli-discovery-pipeline-design.md | 235 ----- ...6-07-17-genesis-operating-system-design.md | 547 ---------- 10 files changed, 2820 deletions(-) delete mode 100644 .superpowers/sdd/progress.md delete mode 100644 .superpowers/sdd/task-4-report.md delete mode 100644 .superpowers/sdd/task-5-report.md delete mode 100644 .superpowers/sdd/task-6-report.md delete mode 100644 .superpowers/sdd/task-7-report.md delete mode 100644 .superpowers/sdd/task-8-report.md delete mode 100644 docs/superpowers/plans/2026-07-17-genesis-cli-discovery-pipeline.md delete mode 100644 docs/superpowers/plans/2026-07-17-genesis-operating-system.md delete mode 100644 docs/superpowers/specs/2026-07-17-genesis-cli-discovery-pipeline-design.md delete mode 100644 docs/superpowers/specs/2026-07-17-genesis-operating-system-design.md diff --git a/.superpowers/sdd/progress.md b/.superpowers/sdd/progress.md deleted file mode 100644 index f7981ac..0000000 --- a/.superpowers/sdd/progress.md +++ /dev/null @@ -1,14 +0,0 @@ -# Genesis CLI Discovery Pipeline SDD Progress - -Plan: docs/superpowers/plans/2026-07-17-genesis-cli-discovery-pipeline.md -Branch base: 5daa7fc -Approval: records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml (validated 2026-07-17) - -Task 1: complete (commits 5daa7fc..9bd4400, review clean) -Task 2: complete (commits 9bd4400..e443b7d, review approved; minor optional branch coverage noted for final review) -Task 3: complete (commits e443b7d..b91d095, review approved after fix) -Task 4: complete (storage layer and tests passed) -Task 5: complete (rebuildable SQLite projection and tests passed) -Task 6: complete (discovery gate, preregistration completeness, and supported metrics passed) -Task 7: complete (human-confirmed application service passed) -Task 8: complete (interactive CLI and resumable commands passed) diff --git a/.superpowers/sdd/task-4-report.md b/.superpowers/sdd/task-4-report.md deleted file mode 100644 index 83f3574..0000000 --- a/.superpowers/sdd/task-4-report.md +++ /dev/null @@ -1,16 +0,0 @@ -# Task 4 Report - -Implemented the private workspace and append-only YAML storage layer. - -Changed: -- Added `.genesis/` to `.gitignore`. -- Added `src/storage/workspace.mjs` with `workspacePaths`, `ensureWorkspace`, and `withWorkspaceLock`. -- Added `src/storage/yaml-record-store.mjs` with `writeRecord`, `readRecord`, and `listRecords`. -- Added `tests/storage.test.mjs` covering private directory creation, append-only writes, duplicate version rejection, workspace locking, invalid kinds, and sorted record listing. - -Verification: -- `node --test tests/storage.test.mjs` -- `npm run check` - -Concerns: -- None after the listing kind-name fix. diff --git a/.superpowers/sdd/task-5-report.md b/.superpowers/sdd/task-5-report.md deleted file mode 100644 index 3d85b78..0000000 --- a/.superpowers/sdd/task-5-report.md +++ /dev/null @@ -1,19 +0,0 @@ -# Task 5 Report: SQLite projection and deterministic rebuild - -Implemented `src/storage/projection.mjs` and `tests/projection.test.mjs`. - -Changes: -- Added `openProjection(dbPath)` to create the projection schema with foreign keys enabled. -- Added `projectRecord(db, descriptor, record)` with SQLite transaction semantics for decision, experiment, and evidence projections. -- Added `recordBlockedCommand(db, event)` and `readOpportunity(db, businessId)`. -- Added `projectionConsistency(db, descriptors)` for source/projection count checks. -- Added `rebuildProjection({ projectRoot, registry })` to rebuild into `.genesis/genesis.db.rebuild.tmp`, validate YAML, ignore `.tmp` YAML files, preserve the prior database on failure, and atomically replace `genesis.db` on success. -- Added focused tests covering latest-path projection, counts, snapshot equality after rebuild, `.tmp` file filtering, blocked command recording, and fail-closed behavior when YAML is malformed. - -Tests run: -- `node --test tests/projection.test.mjs` -- `npm run check` -- `git diff --check` - -Concerns: -- `projectionConsistency` is intentionally conservative and only checks the local projection state and registered record types. If the record model expands, the descriptor registry and projection rules should be updated together. diff --git a/.superpowers/sdd/task-6-report.md b/.superpowers/sdd/task-6-report.md deleted file mode 100644 index ad0f157..0000000 --- a/.superpowers/sdd/task-6-report.md +++ /dev/null @@ -1,36 +0,0 @@ -# Task 6 Report - -Implemented the discovery workflow layer for Genesis CLI gating and early metrics. - -Changes made: - -- Added `src/core/discovery-workflow.mjs` with: - - `evaluateDiscoverGate({ decision, evidence })` - - `experimentCompleteness(experiment)` - - `buildStatus({ decisionVersions, experimentVersions, evidence, blockedCommands, consistency, now })` -- Added `src/core/metrics.mjs` with: - - `calculateMetrics(input)` - - workflow-derived preregistration field loading from `config/workflows/experiment-lifecycle.yaml` -- Added `tests/discovery-workflow.test.mjs` covering: - - isolated Discover gate blocks - - full gate pass - - preregistration completeness loading from YAML - - exact missing-path reporting - - complete draft status behavior - - supported metrics output - -Verification run: - -- `node --test tests/discovery-workflow.test.mjs` -- `npm run check` - -Notes: - -- Metrics are limited to the supported fields in the brief. -- Elapsed-day calculations use millisecond differences without rounding. -- The completeness logic reads preregistration requirements from the workflow YAML rather than duplicating the normative list in application code. - -Concerns: - -- No remaining functional concerns from Task 6 verification. -- Later CLI tasks will need to consume these helpers consistently for status rendering and command routing. diff --git a/.superpowers/sdd/task-7-report.md b/.superpowers/sdd/task-7-report.md deleted file mode 100644 index 9964f98..0000000 --- a/.superpowers/sdd/task-7-report.md +++ /dev/null @@ -1,39 +0,0 @@ -# Task 7 Report - -Implemented the human-confirmed Genesis application service that writes immutable YAML records, updates the rebuildable SQLite projection, and stops at the approval boundary. - -Changes made: - -- Added `src/application/genesis-service.mjs` with: - - `createGenesisService({ projectRoot, repoRoot, clock, confirm })` - - `startBusiness(input)` - - `addEvidence(businessId, input)` - - `status(businessId)` - - `planExperiment(businessId, input)` - - `rebuildIndex()` -- The service now: - - writes Decision and Evidence YAML under a workspace lock - - versions Decision history immutably - - computes status from canonical YAML and supported metrics - - records blocked commands when Discover is incomplete - - returns `COMMAND_UNAVAILABLE` once approval pending already exists - - falls back to `PROJECTION_STALE` if YAML is safe but SQLite projection fails -- Added `tests/genesis-service.test.mjs` covering: - - start-business creation - - add-evidence versioning - - draft experiment planning and approval-pending stop - - repeated start rejection - - confirmation cancellation - - blocked Discover planning - - command unavailability after approval pending - - rebuild-index recovery - -Verification run: - -- `node --test tests/genesis-service.test.mjs` - -Notes: - -- The service keeps YAML as the durable source of truth and treats SQLite as a rebuildable projection. -- Status output is derived from canonical records rather than ad hoc state. -- The service is ready for the later CLI layer to consume directly. diff --git a/.superpowers/sdd/task-8-report.md b/.superpowers/sdd/task-8-report.md deleted file mode 100644 index bc07673..0000000 --- a/.superpowers/sdd/task-8-report.md +++ /dev/null @@ -1,40 +0,0 @@ -# Task 8 Report - -Implemented the interactive Genesis CLI layer for the approved discovery workflow. - -Changes made: - -- Added `src/cli/prompter.mjs` with scripted and terminal-friendly prompt handling. -- Added `src/cli/render.mjs` with proposal, status, rebuild-result, and error rendering. -- Added `src/cli/run-cli.mjs` with routing for: - - `genesis start-business` - - `genesis add-evidence ` - - `genesis status ` - - `genesis plan-experiment ` - - `genesis rebuild-index` -- The CLI: - - prints proposal YAML before confirmation - - stays offline - - uses the confirmed application service - - returns `2` for unknown commands - - returns `1` for Genesis errors - - returns `0` for successful commands -- Added `tests/cli-integration.test.mjs` covering: - - start-business flow - - add-evidence flow - - status rendering - - plan-experiment flow - - rebuild-index - - unknown commands - - fail-closed validation errors - -Verification run: - -- `node --test tests/cli-integration.test.mjs` -- `node bin/genesis.mjs --help` -- `npm run check` - -Notes: - -- The CLI uses a scripted prompter in tests and a real readline prompter in normal use. -- Proposal rendering happens before confirmation so the user sees the full immutable record before saving. diff --git a/docs/superpowers/plans/2026-07-17-genesis-cli-discovery-pipeline.md b/docs/superpowers/plans/2026-07-17-genesis-cli-discovery-pipeline.md deleted file mode 100644 index 3b4f618..0000000 --- a/docs/superpowers/plans/2026-07-17-genesis-cli-discovery-pipeline.md +++ /dev/null @@ -1,884 +0,0 @@ -# Genesis CLI Discovery Pipeline 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:** Deliver an offline, resumable `genesis` CLI that creates immutable Decision and Experiment YAML records, maintains a rebuildable SQLite projection, reports supported early metrics, and stops at `approval_pending` before any real-world experiment execution. - -**Architecture:** The CLI is a thin terminal adapter over an application service. The service validates proposals against canonical Genesis schemas and workflow rules, writes append-only YAML first, and then updates a disposable SQLite projection. Deterministic offline suggestions assist the human without becoming evidence or advancing state. All state-changing commands are confirmed, locked, fail closed, and recoverable from YAML. - -**Tech Stack:** Node.js 22, ECMAScript modules, npm, `yaml` 2.8.3, Ajv 8.18.0, `ajv-formats` 3.0.1, `better-sqlite3` 12.11.1, Node's built-in test runner, JSON Schema Draft 2020-12, YAML 1.2, SQLite 3. - -## Global Constraints - -- External decision: determine whether one human-confirmed opportunity is ready to leave Discover and receive a validation plan. -- Owner: `builder`. Review date: 2026-08-17. Sunset date: 2027-01-17. -- Before Task 1, execution must find and validate `records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml`. It must be an active, unexpired Human Authority `protected_action` record for actor `codex-agent` and action `sandboxed_cli_pipeline_implementation`. The evidence snapshot and rationale must identify the Decision/Experiment schema alignment, transition-validator alignment, offline CLI, local YAML storage, and rebuildable SQLite projection. Its envelope must allow USD 0 cash, eight labor hours, seven calendar days, `public` and `internal` data, and `high` risk. The implementer must not create, infer, broaden, or repair this approval. -- If the approval record is absent, expired, revoked, ambiguous, or mismatched, stop before changing normative files and report the exact validation issue to `genesis-owner`. Read-only inspection remains allowed. -- `genesis.yaml` and its referenced YAML and schemas remain normative. Markdown and this plan are explanatory and cannot grant authority. -- YAML under `.genesis/` is canonical runtime evidence. SQLite is a derived projection and may be deleted and rebuilt. -- Every confirmed record version is immutable. A correction creates the next version and links the preceding path through `immutable_history_refs`. -- Runtime data is local and Git-ignored. Version one stores user-entered references and summaries; it never copies referenced files. -- Privacy defaults to `internal`. Version one rejects `restricted` evidence and any workflow that proposes sensitive-data collection. -- The CLI has no network adapter, API key, autonomous agent, experiment runner, outreach, deployment, billing, payment, or production capability. -- Suggestions are deterministic, visibly labeled, and never count as evidence without a human-supplied source reference and explicit confirmation. -- `plan-experiment` creates a `draft` Experiment Record and projected `approval_pending` state. No version-one command can enter `running`. -- Stable errors include `code`, affected `path`, expected `correction`, and `escalation` target. Missing or contradictory state fails closed. -- Each task uses red-green-refactor: add one focused failing test, observe the expected failure, add the smallest implementation, rerun the focused test, then commit. -- Run `npm run check` after every normative task and before completion. Do not push or open a pull request without separately valid publication authority. - -## File Responsibility Map - -- `bin/genesis.mjs`: executable entry point and process exit code. -- `src/cli/run-cli.mjs`: argument routing and command orchestration. -- `src/cli/prompter.mjs`: injectable terminal questions and confirmation. -- `src/cli/render.mjs`: stable human-readable proposals, status, warnings, and errors. -- `src/application/genesis-service.mjs`: transactional application operations. -- `src/core/errors.mjs`: stable error type and formatting. -- `src/core/ids.mjs`: business IDs and immutable version filenames. -- `src/core/suggestions.mjs`: deterministic offline suggestions. -- `src/core/schema-registry.mjs`: Ajv compilation for canonical and runtime schemas. -- `src/core/record-builders.mjs`: canonical Decision, Experiment, and evidence construction. -- `src/core/discovery-workflow.mjs`: Discover gates, experiment completeness, and current status. -- `src/core/metrics.mjs`: only metrics supported by local evidence. -- `src/storage/workspace.mjs`: paths, private directories, and writer lock. -- `src/storage/yaml-record-store.mjs`: append-only atomic YAML persistence. -- `src/storage/projection.mjs`: SQLite projection, consistency check, and rebuild. -- `schemas/records/*.schema.json`: normative canonical record shapes. -- `schemas/runtime/evidence-entry.schema.json`: local evidence-entry shape; not a normative record-template registration. -- `tests/*.test.mjs`: focused unit, storage, service, CLI, recovery, and no-network behavior. - ---- - -### Task 1: Verify authority and align canonical record/transition contracts - -**Files:** -- Require: `records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml` -- Modify: `schemas/records/decision-record.schema.json` -- Modify: `schemas/records/experiment-record.schema.json` -- Modify: `config/workflows/experiment-lifecycle.yaml` -- Modify: `templates/decision-record.yaml` -- Modify: `templates/experiment-record.yaml` -- Modify: `scripts/validate-genesis.mjs` -- Modify: `tests/records.test.mjs` -- Modify: `tests/invariants.test.mjs` - -**Interfaces:** -- Extends: `validateApproval(record, { now, action, actor, limits? }) -> ValidationIssue[]` -- Changes: `validateTransition(policySet, workflowId, from, to, context) -> ValidationIssue[]` -- Consumes canonical `context.records`, `context.approvals`, and `context.experimentRecord`; removes ad hoc approval, record-type, preregistration-field, and closure-field shapes. - -- [ ] **Step 1: Validate execution authority before touching normative files** - -Run: - -```bash -test -f records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml -npm run validate -node --input-type=module -e "import fs from 'node:fs'; import Ajv2020 from 'ajv/dist/2020.js'; import addFormats from 'ajv-formats'; import { parseYamlFile, validateApproval } from './scripts/validate-genesis.mjs'; const p='records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml'; const r=parseYamlFile(p); const a=new Ajv2020({allErrors:true,strict:true}); addFormats(a); const schema=JSON.parse(fs.readFileSync('schemas/records/approval-record.schema.json','utf8')); const ok=a.compile(schema)(r); const e=validateApproval(r,{now:new Date().toISOString(),action:'sandboxed_cli_pipeline_implementation',actor:'codex-agent'}); const envelope=r.limits?.cash_usd>=0&&r.limits?.labor_hours>=8&&r.limits?.duration_days>=7&&['public','internal'].every(x=>r.limits?.data_classes?.includes(x))&&['high','critical'].includes(r.limits?.risk_level); const authority=r.status==='active'&&r.approver_role==='human_authority'&&r.approver_principal_id==='genesis-owner'&&r.action_class==='protected_action'; if(!ok||e.length||!envelope||!authority){console.error(JSON.stringify({schema:a.errors,approval:e,envelope,authority},null,2));process.exit(1)}" -``` - -Expected: all commands exit 0. If any command fails, stop this plan and escalate to `genesis-owner`; do not continue to Step 2. - -- [ ] **Step 2: Replace ad hoc transition fixtures with canonical record-shaped fixtures** - -In `tests/invariants.test.mjs`, remove `COMPLETE_PREREGISTRATION`. Add helpers that clone the schema-valid templates and override only fields relevant to the test: - -```js -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, - effective_at: "2026-07-17T00:00:00Z", - expires_at: "2026-07-19T00:00:00Z", - review_at: "2026-07-18T00:00:00Z", - ...overrides, - }); -} -``` - -Add assertions for: - -```js -const passedValidation = await canonicalRecord("experiment_record", { - subtype: "validation", - status: "closed", - validation_outcome: "passed", -}); -assert.deepEqual(transitionIssues(policySet, "business_lifecycle", "validate", "build", { - records: [passedValidation], now: "2026-07-18T00:00:00Z", actor: "codex-agent", -}), []); - -const draft = await canonicalRecord("experiment_record", { - status: "draft", approval_references: [], -}); -assert.deepEqual(transitionIssues(policySet, "experiment_lifecycle", "approval", "running", { - experimentRecord: draft, - approvals: [await activeApproval("experiment", "ceo")], - now: "2026-07-18T00:00:00Z", - actor: "codex-agent", -}), []); -``` - -Also assert that an expired approval, wrong actor, missing preregistration value, and non-closed validation record each produce the existing stable gate code. Add direct `validateApproval` cases in `tests/records.test.mjs` proving requested cash, labor, duration, data classes, and risk cannot exceed the canonical approval envelope; each mismatch must produce `APPROVAL_LIMIT_MISMATCH`. - -Assert that the workflow's preregistration and closure names resolve directly against an Experiment Record. The canonical nested paths are `metric.formula`, `metric.population`, `metric.denominator`, `metric.data_source`, `limits.cash_usd`, `limits.labor_hours`, `limits.duration_days`, `limits.data_classes`, and `limits.risk_level`; the canonical Experience link is `experience_reference`. - -- [ ] **Step 3: Run the transition tests and observe the contract mismatch** - -Run: `node --test --test-name-pattern='Build requires|experiment Approval|experiment closure' tests/invariants.test.mjs` - -Expected: FAIL because `validateTransition` still reads `recordTypes`, `preregistrationFields`, `closureFields`, and `{ approver, action, valid }`. - -- [ ] **Step 4: Write failing record-schema tests for lifecycle-conditional requirements** - -In `tests/records.test.mjs`, add tests proving: - -```js -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); -}); -``` - -- [ ] **Step 5: Run the schema tests and verify they fail for the intended reasons** - -Run: `node --test --test-name-pattern='draft experiment|closed validation|target customer' tests/records.test.mjs` - -Expected: FAIL because closure fields are always required, the validation fields do not exist, and `target_customer` is not required. - -- [ ] **Step 6: Make the smallest normative schema and template alignment** - -In `decision-record.schema.json`, require and define: - -```json -"target_customer": { "$ref": "#/$defs/nonempty" } -``` - -In `experiment-record.schema.json`: - -- add `subtype` with enum `validation`; -- add `validation_outcome` with enum `pending`, `passed`, `failed`; -- add `confidence_update` as number-or-null from 0 through 1; -- add `decision_outcome` as null or one of `scale`, `pivot`, `learning_lab`, `archive`, `kill`; -- keep every preregistration field in the base `required` array; -- remove closure fields from the base `required` array; -- change `approval_references` to `recordRefs` at the property level; -- add an `allOf` branch requiring nonempty approval references for `active` and `closed`; -- add an `allOf` branch requiring all closure fields for `closed` and restricting `validation_outcome` to `passed` or `failed`. - -Use this conditional shape: - -```json -{ - "if": { "properties": { "status": { "const": "closed" } }, "required": ["status"] }, - "then": { - "required": ["actual_cost", "results", "reflection", "outcome", "experience_reference", "confidence_update", "decision_outcome"], - "properties": { "validation_outcome": { "enum": ["passed", "failed"] } } - } -} -``` - -Update the example templates with `target_customer`, `subtype: validation`, `validation_outcome: passed`, `confidence_update: 0.60`, and `decision_outcome: pivot`. - -In `config/workflows/experiment-lifecycle.yaml`, replace the conceptual aliases `metric_formula`, `metric_population`, `metric_denominator`, `metric_data_source`, `maximum_cash`, `maximum_labor`, `maximum_duration`, `maximum_data`, and `maximum_risk` with the canonical nested paths listed in Step 2. Replace `linked_experience_record` with `experience_reference` in `closure_required_fields` and state evidence lists. This lets the validator resolve the normative path rather than maintaining a second alias map. - -- [ ] **Step 7: Make transition validation consume canonical records** - -Replace `validApproval` with: - -```js -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 - )); -} -``` - -Extend `validateApproval` with an optional requested `limits` object. Compare cash, labor, and duration numerically; require every requested data class to appear in the approved `data_classes`; and compare risk using `low < medium < high < critical`. Any excess or unknown value returns `APPROVAL_LIMIT_MISMATCH` at `/limits`. Pass the Experiment Record's `limits` through `matchingApproval` for Approval to Running and pass the bounded learning-prototype limits for its exception path. - -For Validate to Build, accept only: - -```js -const passedValidation = context.records?.some((record) => ( - record?.record_type === "experiment_record" - && record.subtype === "validation" - && record.status === "closed" - && record.validation_outcome === "passed" -)); -``` - -For Approval to Running, validate `context.experimentRecord` against the compiled Experiment schema, resolve every dot-separated workflow preregistration path against that record, confirm each value is nonempty, and call `matchingApproval` using `context.actor`, `context.now`, and the record's `limits`. For Decision to Closed, require a canonical closed Experiment record with every configured closure path. Preserve existing error codes and paths. - -- [ ] **Step 8: Run focused and complete gates** - -Run: - -```bash -node --test tests/records.test.mjs tests/invariants.test.mjs -npm run check -``` - -Expected: both exit 0; all templates remain schema-valid and all negative fixtures remain rejected. - -- [ ] **Step 9: Commit the canonical-contract change** - -```bash -git add records/approvals/approval-cli-discovery-pipeline-2026-07-17.yaml schemas/records/decision-record.schema.json schemas/records/experiment-record.schema.json config/workflows/experiment-lifecycle.yaml templates/decision-record.yaml templates/experiment-record.yaml scripts/validate-genesis.mjs tests/records.test.mjs tests/invariants.test.mjs -git commit -m "Align canonical discovery record contracts" -``` - ---- - -### Task 2: Add the runtime package, stable errors, identifiers, and suggestions - -**Files:** -- Modify: `package.json` -- Modify: `package-lock.json` -- Create: `bin/genesis.mjs` -- Create: `src/core/errors.mjs` -- Create: `src/core/ids.mjs` -- Create: `src/core/suggestions.mjs` -- Create: `tests/cli-core.test.mjs` - -**Interfaces:** -- `GenesisError(code, message, { path, correction, escalation, cause })` -- `formatError(error) -> string` -- `normalizeBusinessId(value) -> string` -- `versionFileName(id, version) -> string` -- `suggestionsFor(topic) -> readonly string[]` - -- [ ] **Step 1: Write failing core tests** - -Create `tests/cli-core.test.mjs`: - -```js -import assert from "node:assert/strict"; -import test from "node:test"; -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); -}); -``` - -- [ ] **Step 2: Observe missing modules** - -Run: `node --test tests/cli-core.test.mjs` - -Expected: FAIL with `ERR_MODULE_NOT_FOUND` for `src/core/errors.mjs`. - -- [ ] **Step 3: Add runtime dependencies and executable metadata** - -Move `ajv`, `ajv-formats`, and `yaml` from `devDependencies` to `dependencies`; add `better-sqlite3: 12.11.1`; and add: - -```json -"bin": { "genesis": "./bin/genesis.mjs" } -``` - -Run: `npm install` - -Expected: exit 0, lockfile version 3, and `npm ls better-sqlite3` reports `12.11.1`. - -- [ ] **Step 4: Implement the minimal core interfaces** - -`GenesisError` must set `name`, `code`, `path`, `correction`, `escalation`, and optional `cause`. `normalizeBusinessId` must lowercase, replace non-alphanumerics with single hyphens, trim hyphens, and enforce the canonical ID regex. `versionFileName` must reject nonpositive/noninteger versions. `suggestionsFor` must return frozen copies from a closed map of supported topics. - -Create `bin/genesis.mjs` with a shebang and a temporary call to the later `runCli` module: - -```js -#!/usr/bin/env node -import { runCli } from "../src/cli/run-cli.mjs"; -process.exitCode = await runCli(process.argv.slice(2)); -``` - -Do not invoke the binary until Task 8 creates `run-cli.mjs`. - -- [ ] **Step 5: Run tests and commit** - -```bash -node --test tests/cli-core.test.mjs -npm run check -git add package.json package-lock.json bin/genesis.mjs src/core tests/cli-core.test.mjs -git commit -m "Add Genesis CLI runtime foundation" -``` - -Expected: tests and full gate exit 0. - ---- - -### Task 3: Validate and build canonical runtime records - -**Files:** -- Create: `schemas/runtime/evidence-entry.schema.json` -- Create: `src/core/schema-registry.mjs` -- Create: `src/core/record-builders.mjs` -- Create: `tests/record-builders.test.mjs` - -**Interfaces:** -- `createSchemaRegistry(repoRoot) -> { validateRecord(recordType, value), validateEvidence(value) }` -- `buildEvidenceEntry(input, clock) -> EvidenceEntry` -- `buildDecisionRecord(input, clock) -> DecisionRecord` -- `versionDecisionRecord(previous, changes, historyRef, clock) -> DecisionRecord` -- `buildExperimentRecord(input, clock) -> ExperimentRecord` - -- [ ] **Step 1: Write failing schema-registry and builder tests** - -Use a fixed clock `() => new Date("2026-07-18T00:00:00Z")`. Assert that: - -```js -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); - -const decision = buildDecisionRecord(validDecisionInput, clock); -assert.equal(registry.validateRecord("decision_record", decision), decision); -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"]); -``` - -Also assert that `restricted` evidence throws `SENSITIVE_DATA_FORBIDDEN`, invalid confidence throws `RECORD_SCHEMA_INVALID`, and an Experiment draft has `approval_references: []`, `validation_outcome: pending`, and no closure-only fields. - -- [ ] **Step 2: Run and observe missing modules** - -Run: `node --test tests/record-builders.test.mjs` - -Expected: FAIL with `ERR_MODULE_NOT_FOUND`. - -- [ ] **Step 3: Define the evidence schema** - -Create a closed JSON schema requiring exactly: - -```json -{ - "id": "canonical-id", - "business_id": "canonical-id", - "collected_at": "date-time", - "source_reference": "nonempty string", - "summary": "nonempty string", - "stance": "support | contradict", - "provenance": "nonempty string", - "privacy_classification": "public | internal | confidential | restricted" -} -``` - -Set `additionalProperties: false`. Do not register this file in `genesis.yaml`; it is a runtime entry, not a new canonical record type. - -- [ ] **Step 4: Implement registry and builders** - -Compile the manifest-registered record schemas with Ajv 2020 and `ajv-formats`. Return the original value on success. On failure, throw `GenesisError("RECORD_SCHEMA_INVALID", "Record failed its registered schema", { path: firstInstancePath, correction: allAjvMessages, escalation: "builder" })`. - -Builders must: - -- use policy version `2.0.0` and schema version `1.0.0`; -- create stable IDs from the business ID; -- set both timestamps from the injected clock; -- default privacy to `internal`; -- never synthesize evidence, counterevidence, approval, results, or outcome; -- validate after construction, before returning. - -- [ ] **Step 5: Run tests and commit** - -```bash -node --test tests/record-builders.test.mjs -npm run check -git add schemas/runtime/evidence-entry.schema.json src/core/schema-registry.mjs src/core/record-builders.mjs tests/record-builders.test.mjs -git commit -m "Build schema-valid discovery records" -``` - ---- - -### Task 4: Add private workspaces and append-only atomic YAML storage - -**Files:** -- Modify: `.gitignore` -- Create: `src/storage/workspace.mjs` -- Create: `src/storage/yaml-record-store.mjs` -- Create: `tests/storage.test.mjs` - -**Interfaces:** -- `workspacePaths(projectRoot) -> { root, records, decisions, experiments, evidence, db, lock }` -- `ensureWorkspace(projectRoot) -> WorkspacePaths` -- `withWorkspaceLock(projectRoot, operation) -> Promise` -- `writeRecord({ projectRoot, kind, id, version, value }) -> { absolutePath, relativePath }` -- `readRecord(absolutePath) -> object` -- `listRecords(projectRoot) -> RecordDescriptor[]` - -- [ ] **Step 1: Write failing storage tests** - -In a fresh `mkdtemp` directory, assert: - -```js -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(await readRecord(written.absolutePath), value); -await assert.rejects(() => writeRecord({ projectRoot, kind: "decision", id: "bakery-decision", version: 1, value }), { code: "RECORD_VERSION_EXISTS" }); -``` - -Acquire one lock and assert a nested competing lock fails with `WORKSPACE_LOCKED`; then assert the lock file is removed after both success and thrown-operation paths. Assert `listRecords` sorts by kind, ID, then numeric version and ignores `.tmp` files. - -- [ ] **Step 2: Observe missing modules** - -Run: `node --test tests/storage.test.mjs` - -Expected: FAIL with `ERR_MODULE_NOT_FOUND`. - -- [ ] **Step 3: Implement private workspace creation and locking** - -Add `.genesis/` to `.gitignore`. `ensureWorkspace` creates directories with mode `0700`. `withWorkspaceLock` opens `.genesis/workspace.lock` with flag `wx`, writes the PID and timestamp, and removes only the exact lock it created in `finally`. An existing lock fails closed; version one does not guess whether it is stale. - -- [ ] **Step 4: Implement append-only atomic writes** - -For each write: - -1. validate `kind` against `decision`, `experiment`, `evidence`; -2. create the final version filename deterministically; -3. fail if the final path exists; -4. serialize with `YAML.stringify`; -5. create a same-directory unique `.tmp` file with mode `0600` and exclusive creation; -6. write, `sync`, close, and rename to the final path; -7. remove the exact temporary path on failure. - -Never overwrite a final YAML path. Return paths only after rename succeeds. - -- [ ] **Step 5: Run tests and commit** - -```bash -node --test tests/storage.test.mjs -npm run check -git add .gitignore src/storage tests/storage.test.mjs -git commit -m "Persist immutable Genesis YAML records" -``` - ---- - -### Task 5: Build the disposable SQLite projection and deterministic rebuild - -**Files:** -- Create: `src/storage/projection.mjs` -- Create: `tests/projection.test.mjs` - -**Interfaces:** -- `openProjection(dbPath) -> Database` -- `projectRecord(db, descriptor, record) -> void` -- `recordBlockedCommand(db, event) -> void` -- `readOpportunity(db, businessId) -> OpportunityProjection | null` -- `projectionConsistency(db, descriptors) -> { consistent, yamlCount, projectedCount }` -- `rebuildProjection({ projectRoot, registry }) -> { recordCount, businessCount }` - -- [ ] **Step 1: Write failing projection tests** - -Create two Decision versions, one evidence entry, and one draft Experiment in a temporary workspace. Assert projection tables expose the latest paths, state `approval_pending`, support/contradict counts, confidence, and timestamps. Delete `genesis.db`, rebuild, and assert the rebuilt rows deeply equal a normalized snapshot of the original rows. - -Also create malformed YAML and assert rebuild fails with `RECORD_SCHEMA_INVALID` while the prior database remains byte-for-byte unchanged. - -- [ ] **Step 2: Observe missing projection module** - -Run: `node --test tests/projection.test.mjs` - -Expected: FAIL with `ERR_MODULE_NOT_FOUND`. - -- [ ] **Step 3: Create the projection schema** - -`openProjection` must enable foreign keys and create: - -```sql -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 -); -``` - -- [ ] **Step 4: Implement projection and rebuild** - -Wrap each projection update in a SQLite transaction. Upsert `record_versions`; derive the opportunity row only from canonical record content and descriptors. Rebuild into `.genesis/genesis.db.rebuild.tmp`, validate every YAML entry, verify source/projected counts, close both databases, then rename the temporary database over `genesis.db`. If any step fails, close and remove only the temporary database, preserving the prior database. - -- [ ] **Step 5: Run tests and commit** - -```bash -node --test tests/projection.test.mjs -npm run check -git add src/storage/projection.mjs tests/projection.test.mjs -git commit -m "Add rebuildable Genesis SQLite projection" -``` - ---- - -### Task 6: Evaluate Discover gates, preregistration completeness, and supported metrics - -**Files:** -- Create: `src/core/discovery-workflow.mjs` -- Create: `src/core/metrics.mjs` -- Create: `tests/discovery-workflow.test.mjs` - -**Interfaces:** -- `evaluateDiscoverGate({ decision, evidence }) -> { passed, blockers }` -- `experimentCompleteness(experiment) -> { complete, present, required, missing, ratio }` -- `buildStatus({ decisionVersions, experimentVersions, evidence, blockedCommands, consistency, now }) -> Status` -- `calculateMetrics(input) -> SupportedMetrics` - -- [ ] **Step 1: Write failing workflow tests** - -Assert that Discover is blocked separately for missing target customer, problem, hypothesis, and zero evidence. One confirmed evidence entry plus all required Decision fields passes. Assert that a complete draft experiment yields ratio `1`, state `approval_pending`, and next permitted command `status`; an incomplete draft lists exact JSON-pointer-like missing paths. - -For a fixed clock, assert metrics exactly equal: - -```js -{ - 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 -} -``` - -- [ ] **Step 2: Observe missing modules** - -Run: `node --test tests/discovery-workflow.test.mjs` - -Expected: FAIL with `ERR_MODULE_NOT_FOUND`. - -- [ ] **Step 3: Implement deterministic gates and metrics** - -Read preregistration requirements from `config/workflows/experiment-lifecycle.yaml`; do not duplicate a second normative list in application code. Treat trimmed empty strings, empty arrays, `null`, and `undefined` as missing. Calculate elapsed days as milliseconds divided by `86_400_000` without rounding. Do not produce realized value, experiment success, customer-reality ratio, or forecast calibration. - -- [ ] **Step 4: Run tests and commit** - -```bash -node --test tests/discovery-workflow.test.mjs -npm run check -git add src/core/discovery-workflow.mjs src/core/metrics.mjs tests/discovery-workflow.test.mjs -git commit -m "Evaluate discovery gates and early metrics" -``` - ---- - -### Task 7: Implement the human-confirmed application service - -**Files:** -- Create: `src/application/genesis-service.mjs` -- Create: `tests/genesis-service.test.mjs` - -**Interfaces:** -- `createGenesisService({ projectRoot, repoRoot, clock, confirm }) -> GenesisService` -- `service.startBusiness(input) -> CommandResult` -- `service.addEvidence(businessId, input) -> CommandResult` -- `service.status(businessId) -> Status` -- `service.planExperiment(businessId, input) -> CommandResult` -- `service.rebuildIndex() -> RebuildResult` - -- [ ] **Step 1: Write failing service tests** - -Inject `confirm: async proposal => true` and a fixed clock. Exercise: - -1. `startBusiness` writes evidence and Decision v1 and returns state `discover`; -2. `addEvidence` writes an Evidence entry and Decision v2 with v1 in `immutable_history_refs`; -3. `status` reports gates and counts; -4. `planExperiment` writes a draft Experiment and returns `approval_pending`; -5. repeating `startBusiness` throws `BUSINESS_ALREADY_EXISTS`; -6. declining confirmation returns `{ changed: false, reason: "cancelled" }` and writes nothing; -7. planning while a gate is missing throws `DISCOVER_GATE_BLOCKED` and records one blocked command; -8. requesting any transition beyond approval pending throws `COMMAND_UNAVAILABLE`. - -- [ ] **Step 2: Observe missing service** - -Run: `node --test tests/genesis-service.test.mjs` - -Expected: FAIL with `ERR_MODULE_NOT_FOUND`. - -- [ ] **Step 3: Implement YAML-first service operations** - -Each mutating method must run under `withWorkspaceLock` and follow: - -```js -const proposal = buildAndValidate(input); -if (!await confirm(proposal)) return { changed: false, reason: "cancelled" }; -const yaml = await writeRecord({ - projectRoot, - kind: proposal.record_type === "decision_record" ? "decision" : "experiment", - id: proposal.id, - version: nextVersion, - value: proposal, -}); -try { - projectRecord(db, yaml, proposal); - verifyProjectionReference(db, yaml.relativePath); - return { changed: true, record: proposal, path: yaml.relativePath, projection_stale: false }; -} catch (cause) { - return { changed: true, record: proposal, path: yaml.relativePath, projection_stale: true, - warning: new GenesisError("PROJECTION_STALE", "Canonical YAML is safe but SQLite is stale", { - correction: "Run genesis rebuild-index", escalation: "builder", cause, - }) }; -} -``` - -Never roll back or delete a successfully renamed YAML record because projection failed. Read current truth from YAML when SQLite is missing or marked inconsistent. - -- [ ] **Step 4: Run tests and commit** - -```bash -node --test tests/genesis-service.test.mjs -npm run check -git add src/application/genesis-service.mjs tests/genesis-service.test.mjs -git commit -m "Implement confirmed discovery workflow service" -``` - ---- - -### Task 8: Add the interactive CLI and resumable commands - -**Files:** -- Create: `src/cli/prompter.mjs` -- Create: `src/cli/render.mjs` -- Create: `src/cli/run-cli.mjs` -- Create: `tests/cli-integration.test.mjs` - -**Interfaces:** -- `createPrompter({ input, output }) -> { ask, choose, confirm, close }` -- `renderProposal(proposal) -> string` -- `renderStatus(status) -> string` -- `runCli(argv, dependencies?) -> Promise` - -- [ ] **Step 1: Write failing command-routing integration tests** - -Use temporary project roots and a scripted prompter rather than a real TTY. Assert exact exit codes: - -- `genesis start-business` returns 0 and prints the full proposal before the confirmation question; -- `genesis add-evidence bakery` returns 0 and labels support/contradict; -- `genesis status bakery` returns 0 and prints state, versions, evidence counts, gates, limits, next action, blockers, and projection consistency; -- `genesis plan-experiment bakery` returns 0 and prints `approval_pending`; -- `genesis rebuild-index` returns 0 and prints record/business counts; -- unknown command returns 2 and prints usage; -- policy or validation error returns 1 and prints code, path, correction, and escalation. - -- [ ] **Step 2: Observe missing CLI modules** - -Run: `node --test tests/cli-integration.test.mjs` - -Expected: FAIL with `ERR_MODULE_NOT_FOUND`. - -- [ ] **Step 3: Implement guided prompts and proposal rendering** - -`start-business` asks for business ID, target customer, problem, hypothesis, confidence, initial evidence, counterevidence, alternatives, expected outcome, metric, owner, and review date. `plan-experiment` asks for supported decision, baseline, comparison method, formula, population, denominator, data source, expected outcome, minimum meaningful effect, failure conditions, stop conditions, maximum cash, labor, duration, data classes, risk, owner, decision date, and allowed outcomes. - -Render suggestions under `Offline suggestion — not evidence:`. Render the complete proposed YAML-shaped object before one final `Save this immutable record? [y/N]` prompt. Default confirmation is no. - -- [ ] **Step 4: Implement routing without an execution command** - -Supported command grammar is exactly: - -```text -genesis start-business -genesis add-evidence -genesis status -genesis plan-experiment -genesis rebuild-index -``` - -Do not implement `run-experiment`, `approve`, `advance`, or a generic escape hatch. Resolve `repoRoot` from the installed module and default `projectRoot` to `process.cwd()`. - -- [ ] **Step 5: Run the binary and tests, then commit** - -```bash -node --test tests/cli-integration.test.mjs -node bin/genesis.mjs --help -npm run check -git add src/cli tests/cli-integration.test.mjs bin/genesis.mjs -git commit -m "Add interactive Genesis discovery commands" -``` - -Expected: help exits 0; tests and complete gate exit 0. - ---- - -### Task 9: Prove offline operation, recovery, locking, and fail-closed behavior - -**Files:** -- Create: `tests/no-network.test.mjs` -- Create: `tests/recovery.test.mjs` -- Modify: `tests/cli-integration.test.mjs` - -**Interfaces:** -- Uses only public interfaces from Tasks 4 through 8. - -- [ ] **Step 1: Write a failing no-network test** - -Before importing the application, replace network-capable globals with traps: - -```js -globalThis.fetch = async () => { throw new Error("NETWORK_USED"); }; -``` - -Run the complete scripted flow through `runCli`: start, add contradicting evidence, status, plan, delete SQLite, rebuild, status. Assert every command returns 0 and no output contains `NETWORK_USED`. - -- [ ] **Step 2: Add static network-import protection** - -Scan `bin/` and `src/` and fail if an import specifier matches `node:http`, `node:https`, `node:http2`, `node:net`, `undici`, `axios`, or `openai`, or if production code references `fetch(`. - -Run: `node --test tests/no-network.test.mjs` - -Expected before the complete harness exists: FAIL on the first unimplemented scripted dependency; after wiring, PASS with no network-capable import. - -- [ ] **Step 3: Write recovery and stale-projection tests** - -Inject a projection adapter that throws after YAML rename. Assert the command returns `changed: true`, the YAML exists and validates, the output contains `PROJECTION_STALE`, and `rebuild-index` restores consistency. Simulate a lock conflict and assert no YAML or SQLite change. Place a `.tmp` YAML file beside records and assert rebuild ignores it. - -- [ ] **Step 4: Run resilience tests and commit** - -```bash -node --test tests/no-network.test.mjs tests/recovery.test.mjs tests/cli-integration.test.mjs -npm run check -git add tests/no-network.test.mjs tests/recovery.test.mjs tests/cli-integration.test.mjs -git commit -m "Verify offline recovery and fail-closed CLI behavior" -``` - ---- - -### Task 10: Document operation and perform acceptance verification - -**Files:** -- Modify: `README.md` -- Modify: `Genesis Configuration.md` -- Modify: `.gitignore` -- Modify: `package.json` - -**Interfaces:** -- Documents the five supported CLI commands, YAML authority, SQLite rebuild, privacy boundary, approval stop, and troubleshooting codes. - -- [ ] **Step 1: Add documentation assertions** - -In `tests/configuration.test.mjs`, assert that `README.md` contains all five command strings, `.genesis/`, `approval_pending`, `YAML`, `SQLite`, and `rebuild-index`; assert `Genesis Configuration.md` labels the CLI runtime explanatory and links back to `genesis.yaml` as normative. - -- [ ] **Step 2: Run and observe missing documentation** - -Run: `node --test --test-name-pattern='CLI documentation' tests/configuration.test.mjs` - -Expected: FAIL until the operating documentation is added. - -- [ ] **Step 3: Write concise operator documentation** - -Document installation with `npm ci`, local invocation with `npm link` or `node bin/genesis.mjs`, one manual example flow, exact created paths, immutable version behavior, the approval boundary, supported metrics, privacy limitations, and recovery with `genesis rebuild-index`. State explicitly that the CLI does not research, contact customers, execute experiments, build products, deploy, bill, or operate a business. - -- [ ] **Step 4: Run a clean acceptance sequence** - -Run: - -```bash -npm ci -npm run check -node bin/genesis.mjs --help -git diff --check -git status --short -``` - -Expected: - -- dependency installation succeeds on Node 22; -- validation and all tests pass; -- help lists only the five approved commands; -- no whitespace errors appear; -- status contains only intentional implementation and documentation changes. - -- [ ] **Step 5: Review acceptance criteria manually** - -In a new temporary working directory, complete the five-command flow. Inspect the Decision v1/v2 YAML, evidence YAML, draft Experiment YAML, and SQLite rows. Delete only `genesis.db`, rebuild, and compare status output before and after. Confirm `plan-experiment` ends at `approval_pending` and there is no command capable of further progression. - -- [ ] **Step 6: Record consequential AI verification evidence** - -In the implementation handoff, record model identity, model version, material tool context, repository evidence sources, reviewer, exact verification commands, their exit status, actual cash/labor/duration consumption, and remaining uncertainty. Do not invent a reviewer or verification outcome. - -- [ ] **Step 7: Commit the completed release locally** - -```bash -git add README.md "Genesis Configuration.md" .gitignore package.json package-lock.json -git commit -m "Document Genesis discovery pipeline" -git status --short --branch -``` - -Expected: clean working tree on the implementation branch. Stop here unless a separate valid publication approval authorizes pushing this new work. - ---- - -## Completion Definition - -The implementation is complete only when a clean Node 22 environment can create and resume one opportunity, add supporting and contradicting evidence, show exact Discover blockers, create a schema-valid draft validation Experiment, stop at `approval_pending`, rebuild identical state and metrics from YAML after deleting SQLite, reject mismatched authority and sensitive data, operate with networking disabled, and pass `npm run check`. A passing document-only validation without the manual CLI acceptance flow is not completion. 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-cli-discovery-pipeline-design.md b/docs/superpowers/specs/2026-07-17-genesis-cli-discovery-pipeline-design.md deleted file mode 100644 index 869a2dc..0000000 --- a/docs/superpowers/specs/2026-07-17-genesis-cli-discovery-pipeline-design.md +++ /dev/null @@ -1,235 +0,0 @@ -# Genesis CLI Discovery Pipeline Design - -Policy-Version: 2.0.0 -Authority: Explanatory -Status: User-reviewed design -Date: 2026-07-17 -External-Decision: Decide whether to implement the first operational Genesis pipeline as an offline, human-assisted CLI covering Discover through validation planning. -Owner: builder -Review-Date: 2026-08-17 -Sunset-Date: 2027-01-17 - -## 1. Purpose - -This design defines the first executable operating slice built on Genesis 2.0. It turns the governance package into a resumable local CLI that can register one business opportunity, preserve evidence, create a Decision Record, preregister a validation experiment, calculate supported early metrics, and stop at the approval gate before real-world execution. - -The design does not authorize normative policy changes or protected actions. Any implementation change to a normative schema or validator must follow the applicable decision class, records, versioning, tests, and approval requirements before it becomes effective. - -## 2. Decisions approved during design review - -- The first interface is a CLI rather than a web application. -- Runtime state uses canonical immutable YAML records with a rebuildable SQLite projection. -- The workflow is human-assisted; suggestions cannot save evidence or advance state. -- `genesis start-business` is a guided interactive wizard. -- The first release covers Discover through validation planning and stops at `approval_pending`. -- The CLI works without an external AI API or network access. -- Work is resumable across commands rather than requiring one long session. - -## 3. Goals - -- Create a real, policy-valid opportunity and Decision Record from a guided CLI. -- Add supporting and contradicting evidence without silently rewriting history. -- Explain current lifecycle state, missing gates, permitted next action, and blockers. -- Produce a complete preregistered validation Experiment Record. -- Enforce the approval boundary before experiment execution. -- Preserve YAML as durable evidence while making state and metrics practical through SQLite. -- Recover the complete projection from YAML alone. -- Produce actionable fail-closed errors. - -## 4. Non-goals - -Version one does not: - -- Search the web or call an AI service. -- Contact customers or conduct an experiment. -- Build or deploy a product. -- Enter contracts, spend money, process payments, or handle financial authority. -- Collect or copy sensitive files. -- Implement Build, Launch, Operate, Review, Scale, Pivot, Learning Lab, Archive, or Kill execution. -- Provide a web UI, multi-user service, production database, billing, outreach, or autonomous agents. -- Calculate outcome metrics that require an executed experiment. - -## 5. Architecture - -The implementation remains a Node.js application in the existing repository and is divided into focused components. - -### 5.1 CLI layer - -The CLI owns terminal input and output only. It collects guided answers, displays complete proposals, requests explicit confirmation, and renders success or actionable errors. It does not contain policy, storage, or lifecycle logic. - -### 5.2 Workflow service - -The workflow service loads the normative Genesis policy set, identifies the current lifecycle state, evaluates gates, and returns allowed actions or blockers. It consumes canonical records rather than ad hoc helper shapes. - -### 5.3 Record service - -The record service creates and validates versioned Decision and Experiment Records. Confirmed changes produce new immutable YAML versions. It never rewrites a prior canonical record. - -### 5.4 YAML record store - -YAML is the durable evidence layer. Records are written to a temporary file, validated, and atomically renamed into place. Stable record identifiers and monotonically increasing version numbers identify history. - -### 5.5 SQLite projection - -SQLite stores current lifecycle state, latest record versions, searchable fields, record paths, and supported metrics. It is not authoritative. It may be deleted and rebuilt deterministically from YAML. - -### 5.6 Suggestion provider - -The first provider is deterministic and offline. It offers examples of hypotheses, evidence categories, validation methods, metrics, and stop conditions. Suggestions are labeled, require explicit user selection or editing, and never become evidence automatically. A provider interface allows a future AI adapter without coupling AI behavior to policy or storage. - -## 6. Runtime workspace - -Each working directory receives a private local runtime directory: - -```text -.genesis/ -├── records/ -│ ├── decisions/ -│ └── experiments/ -├── evidence/ -├── genesis.db -└── workspace.lock -``` - -Runtime data is ignored by Git by default. Privacy classification defaults to `internal`. Evidence files are not copied automatically; version one stores user-confirmed references and summaries. - -## 7. Commands and user flow - -### 7.1 `genesis start-business` - -The guided wizard collects the business identifier, target customer, problem, hypothesis, confidence, initial evidence, counterevidence, alternatives, expected outcome, metric, owner, and review date. It shows the complete proposed Decision Record before confirmation. - -After confirmation, the command creates the first immutable Decision Record, initializes the SQLite projection, and places the opportunity in `discover`. - -### 7.2 `genesis add-evidence ` - -The command collects a source reference, summary, collection date, privacy classification, provenance, and whether the item supports or contradicts the current hypothesis. It shows the proposed addition before confirmation. - -The command appends evidence and creates a new Decision Record version when belief, confidence, or the current evidence set changes. Prior versions remain intact. - -### 7.3 `genesis status ` - -The command displays: - -- Current lifecycle state. -- Latest Decision and Experiment Record versions. -- Supporting and contradicting evidence counts. -- Missing exit criteria and preregistration fields. -- Current cash, labor, duration, data, and risk limits. -- The next permitted command. -- Approval, policy, or record blockers. -- SQLite projection consistency. - -### 7.4 `genesis plan-experiment ` - -This command is available only when Discover exit criteria pass. It collects the supported decision, baseline, comparison method, metric formula, population, denominator, data source, expected outcome, minimum meaningful effect, failure conditions, stop conditions, maximum cash, labor, duration, data and risk exposure, owner, decision date, and allowed outcomes. - -After confirmation, it creates a preregistered draft Experiment Record and sets the projected state to `approval_pending`. Version one cannot run the experiment or bypass this state. - -### 7.5 `genesis rebuild-index` - -The command deletes no canonical evidence. It reads every valid YAML version in deterministic order, rebuilds SQLite in a temporary database, verifies the resulting projection, and atomically replaces the stale database. - -## 8. Record and validator alignment - -The current Experiment Record schema requires closure-only fields even when a record is a draft. The current transition helper also expects approval and validation objects whose fields do not match canonical records. - -Implementation therefore requires a separately authorized normative change that: - -- Makes Experiment Record requirements conditional on lifecycle status. -- Requires preregistration fields for `draft` and `active` records. -- Requires actual cost, results, reflection, outcome, confidence update, decision outcome, and Experience linkage only when the record is `closed`. -- Defines a canonical validation subtype and outcome representation that can satisfy the Business Lifecycle Build gate in a later release. -- Makes transition validation consume canonical Approval and Experiment Record fields. -- Validates actor, action, scope, budget, duration, data class, risk class, effective time, expiry, and revocation from the canonical Approval Record. -- Updates schemas, templates, focused tests, negative fixtures, explanatory documentation, and the complete repository gate together. - -This design records the need; it does not itself grant approval or make the change effective. - -## 9. Write sequence and recovery - -Every durable command follows the same sequence: - -1. Collect input. -2. Validate field-level constraints. -3. Display the complete proposed record or evidence addition. -4. Receive explicit confirmation. -5. Validate the proposal against schema, policy, lifecycle, and authority requirements. -6. Write and flush a temporary YAML file. -7. Atomically rename it into the canonical record store. -8. Update SQLite. -9. Verify the projection references the new canonical version. - -If steps 1 through 5 fail, nothing is written. If the process stops before the rename, no canonical partial record exists. If SQLite update or verification fails after the rename, the command reports that evidence is safe but the projection is stale and directs the user to `genesis rebuild-index`. - -## 10. Safety and privacy - -- A workspace lock prevents concurrent writers. -- Missing, invalid, contradictory, ambiguous, expired, revoked, or mismatched authority fails closed. -- Conversation context is never treated as approval. -- Approval requires a schema-valid canonical Approval Record. -- Protected actions remain unavailable in version one. -- Suggestions are visually identified and cannot be cited as evidence without a human-provided source and confirmation. -- Runtime data remains local and Git-ignored by default. -- Privacy defaults to `internal`; version one rejects sensitive-data collection workflows. -- No command makes network calls. -- Stable errors include code, affected record or field, expected correction, and escalation target. - -## 11. Supported metrics - -Version one calculates only metrics supported by its actual data: - -- Supporting evidence count. -- Contradicting evidence count. -- Days spent in Discover. -- Time from opportunity creation to validation plan. -- Preregistration completeness. -- Decision confidence and version history. -- Commands blocked by policy or error code. -- Record and projection consistency. - -It does not calculate realized value, experiment success, customer-reality ratio, forecast calibration, or other outcome metrics before an experiment is executed. - -## 12. Testing strategy - -### 12.1 Unit tests - -Cover identifiers, deterministic suggestions, classifications, record versioning, gate evaluation, and metric calculations. - -### 12.2 Schema and policy tests - -Prove that each record status requires the correct fields, invalid phase combinations fail, canonical approvals enforce every limit, and the existing negative fixtures remain rejected. - -### 12.3 Storage tests - -Cover atomic YAML writes, append-only behavior, interrupted writes, workspace locking, projection updates, projection mismatch detection, and deterministic SQLite rebuilding. - -### 12.4 CLI integration tests - -Run commands in isolated temporary workspaces with scripted input. Verify terminal output, exit codes, YAML contents, version history, SQLite state, and fail-closed behavior. - -### 12.5 No-network test - -Run the complete CLI flow with network access unavailable and assert that no network-capable adapter is invoked. - -## 13. Acceptance criteria - -The first release is complete only when a clean environment can: - -1. Run `genesis start-business` and create a schema-valid opportunity and Decision Record. -2. Resume the opportunity with `genesis add-evidence`. -3. Show missing Discover gates with `genesis status`. -4. Produce a complete preregistered Experiment Record with `genesis plan-experiment`. -5. Stop at `approval_pending` and reject execution without valid approval. -6. Delete SQLite, rebuild it from YAML, and recover identical state and metrics. -7. Reject missing, expired, revoked, actor-mismatched, scope-mismatched, and limit-mismatched approvals. -8. Pass focused tests and the complete `npm run check` gate. -9. Demonstrate that the full version-one flow works without network access or an API key. - -## 14. Later releases - -Later designs may separately add experiment execution and measurement, AI-assisted public research, a web approval interface, full business lifecycle states, customer outreach, product building, deployment, billing, and operations. Each addition receives its own design, plan, authority classification, evidence, and verification. - -## 15. Review and sunset - -The owner reviews this design by 2026-08-17 against implementation evidence, system-overhead ratio, and the external decision it supports. The design sunsets on 2027-01-17 unless renewed, superseded, or closed with recorded rationale. 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. From 9bdc6849fbc65ad65d2bec103735c281843cdaac Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 19:40:20 +0400 Subject: [PATCH 17/18] Expand Genesis user guide --- README.md | 607 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 567 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 91a7605..96b7faa 100644 --- a/README.md +++ b/README.md @@ -1,88 +1,615 @@ # Genesis 2.0 -Genesis is an offline CLI for recording and reviewing discovery work. It creates immutable YAML records under `.genesis/` and rebuilds a SQLite projection from those records when needed. +
-Policy-Version: 2.0.0 +**A local-first, human-governed engine for turning business opportunities into evidence-backed experiment plans.** + +[![Node.js 22+](https://img.shields.io/badge/Node.js-22%2B-339933?logo=nodedotjs&logoColor=white)](https://nodejs.org/) +[![Validation](https://img.shields.io/badge/policy-validated-2563eb)](./scripts/validate-genesis.mjs) +[![Storage](https://img.shields.io/badge/storage-YAML%20%2B%20SQLite-7c3aed)](#how-data-is-stored) +[![Network](https://img.shields.io/badge/runtime-offline-0f766e)](./tests/no-network.test.mjs) +[![Authority](https://img.shields.io/badge/authority-human--governed-b45309)](./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 -## Install +## 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 -npm ci +node --version +npm --version ``` -## Run locally +## Quick start -Use whichever entry point fits your workflow: +Clone and install the locked dependencies: ```bash -npm start +git clone https://github.com/zee-cpu/Genesis.git +cd Genesis +npm ci ``` -or +See the available commands: ```bash node bin/genesis.mjs --help ``` -If you want the command on your PATH during local development, you can also use: +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 ``` -## Supported commands +Genesis always creates its `.genesis/` workspace in the **current working directory**. Run commands from the directory that should own the business records. -- `genesis start-business` -- `genesis add-evidence ` -- `genesis status ` -- `genesis plan-experiment ` -- `genesis rebuild-index` +Start your first opportunity: -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. +```bash +genesis start-business +``` + +The CLI asks questions, prints the complete proposed records, and finishes with: -## What it creates +```text +Save this immutable record? [y/N] +``` -Each workspace gets these paths: +Nothing is saved unless you explicitly answer `y`, `yes`, `true`, or `1`. -- `.genesis/records/decisions/*.yaml` -- `.genesis/records/evidence/*.yaml` -- `.genesis/records/experiments/*.yaml` -- `.genesis/genesis.db` -- `.genesis/workspace.lock` +## Complete walkthrough -Records are immutable versioned YAML files. If the SQLite projection becomes stale, the canonical YAML is still the source of truth and you recover with: +The following example evaluates whether an order-reconciliation tool is worth testing with independent bakery owners. + +### 1. Register the opportunity ```bash -genesis rebuild-index +genesis start-business ``` -## Manual flow +You will be prompted for: -1. Start a business opportunity. -2. Add supporting or contradicting evidence. -3. Check `status` to see counts, gate state, and projection consistency. -4. Plan an experiment when the Discover gate is satisfied. -5. If the database is removed or stale, run `genesis rebuild-index` and re-check `status`. +| 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` | -Example: +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 start-business 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 ``` -## Privacy and scope +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 +├── 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. + +## 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. + +First verify that no Genesis process is still using the workspace. If a process is active, wait for it to finish. 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 YAML persistence | +| `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. -- Use public, internal, or confidential classifications exactly as prompted. -- Do not store secrets or sensitive personal data in records. -- Do not treat prompts, suggestions, or examples as evidence or approval. -- Human Authority approval is required for protected actions and cannot be inferred from silence or previous behavior. +The guiding rule is simple: automate only what has been understood manually, and measure success through better external decisions—not more internal artifacts. -## Documentation boundary +--- -`Genesis Configuration.md` is explanatory only. The normative policy is `genesis.yaml` and its referenced YAML files. +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). From caa61eade44828958c1d23918ce6a59a7e104681 Mon Sep 17 00:00:00 2001 From: zee-cpu Date: Sat, 18 Jul 2026 20:18:20 +0400 Subject: [PATCH 18/18] Harden Genesis workflow integrity --- README.md | 7 +- schemas/records/experiment-record.schema.json | 4 +- scripts/validate-genesis.mjs | 3 +- src/application/genesis-service.mjs | 22 +- src/cli/prompter.mjs | 32 +-- src/cli/run-cli.mjs | 2 +- src/core/discovery-workflow.mjs | 10 +- src/core/metrics.mjs | 9 +- src/storage/projection.mjs | 34 ++- src/storage/workspace.mjs | 124 ++++++++-- src/storage/yaml-record-store.mjs | 213 ++++++++++++++++-- tests/cli-core.test.mjs | 18 ++ tests/cli-integration.test.mjs | 26 +++ tests/discovery-workflow.test.mjs | 17 +- tests/invariants.test.mjs | 13 ++ tests/no-network.test.mjs | 64 +++++- tests/projection.test.mjs | 8 + tests/record-builders.test.mjs | 10 + tests/storage.test.mjs | 102 +++++++++ 19 files changed, 627 insertions(+), 91 deletions(-) diff --git a/README.md b/README.md index 96b7faa..d1d6794 100644 --- a/README.md +++ b/README.md @@ -320,6 +320,7 @@ Genesis creates this structure in the directory where you run it: │ │ └── .v0001.yaml │ └── experiments/ │ └── .v0001.yaml +├── .transactions/ # transient crash-recovery journals, normally empty ├── genesis.db └── workspace.lock # exists only while an operation is active ``` @@ -350,7 +351,7 @@ SQLite is **not** authoritative. If projection fails after YAML is safely writte ### 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. +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 @@ -411,7 +412,7 @@ genesis status Meaning: another Genesis operation is active, or a previous process left a stale lock. -First verify that no Genesis process is still using the workspace. If a process is active, wait for it to finish. Do not remove a lock while a command is running. +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` @@ -497,7 +498,7 @@ graph TD | `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 YAML persistence | +| `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 | diff --git a/schemas/records/experiment-record.schema.json b/schemas/records/experiment-record.schema.json index a7c8576..1eaf76b 100644 --- a/schemas/records/experiment-record.schema.json +++ b/schemas/records/experiment-record.schema.json @@ -24,7 +24,7 @@ "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/dataClasses" }, "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"] }, @@ -67,7 +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" } }, - "dataClasses": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "$ref": "#/$defs/privacy" } }, + "experimentDataClasses": { "type": "array", "minItems": 1, "uniqueItems": true, "items": { "enum": ["public", "internal", "confidential"] } }, "privacy": { "enum": ["public", "internal", "confidential", "restricted"] } } } diff --git a/scripts/validate-genesis.mjs b/scripts/validate-genesis.mjs index 037cd45..053605f 100644 --- a/scripts/validate-genesis.mjs +++ b/scripts/validate-genesis.mjs @@ -554,7 +554,8 @@ export function validateTransition(policySet, workflowId, from, to, context = {} if (workflowId === "business_lifecycle" && from === "validate" && to === "build") { const passedValidation = context.records?.some((record) => ( - record?.record_type === "experiment_record" + validatesRecordSchema(policySet, "experiment_record", record) + && record.record_type === "experiment_record" && record.subtype === "validation" && record.status === "closed" && record.validation_outcome === "passed" diff --git a/src/application/genesis-service.mjs b/src/application/genesis-service.mjs index bc8815f..3f6237f 100644 --- a/src/application/genesis-service.mjs +++ b/src/application/genesis-service.mjs @@ -6,7 +6,7 @@ import { evaluateDiscoverGate, buildStatus } from "../core/discovery-workflow.mj import { GenesisError } from "../core/errors.mjs"; import { normalizeBusinessId } from "../core/ids.mjs"; import { createSchemaRegistry } from "../core/schema-registry.mjs"; -import { listRecords, readRecord, writeRecord } from "../storage/yaml-record-store.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"; @@ -111,7 +111,7 @@ function currentStatus({ projectRoot, businessId, now }) { if (fs.existsSync(paths.db)) { const db = openProjection(paths.db); try { - consistency = projectionConsistency(db, entries.map(({ descriptor }) => descriptor)); + consistency = projectionConsistency(db, listRecords(projectRoot)); blockedCommands = db.prepare( "SELECT code FROM blocked_commands WHERE business_id = ? ORDER BY id", ).all(normalized); @@ -385,19 +385,19 @@ async function persistCommand({ projectRoot, registry, proposal, projectRecords }, ] : []); - for (const item of items) { - const saved = await writeRecord({ - projectRoot, + const savedRecords = await writeRecords({ + projectRoot, + records: items.map((item) => ({ kind: item.kind, id: item.record.id, version: item.version, value: item.record, - }); - written.push({ - ...item, - ...saved, - }); - } + })), + }); + written.push(...items.map((item, index) => ({ + ...item, + ...savedRecords[index], + }))); try { return { diff --git a/src/cli/prompter.mjs b/src/cli/prompter.mjs index cac9ab2..12203d5 100644 --- a/src/cli/prompter.mjs +++ b/src/cli/prompter.mjs @@ -21,20 +21,26 @@ export function createPrompter({ input, output }) { async function choose(question, choices) { const normalized = normalizeChoices(choices); const lines = normalized.map((choice, index) => ` ${index + 1}. ${choice.label}`).join("\n"); - const answer = (await ask(`${question}\n${lines}\n> `)).trim(); - if (!answer) { - return normalized[0]?.value; + 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"); } - - const numeric = Number.parseInt(answer, 10); - 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 - )); - return direct?.value ?? normalized[0]?.value; } async function confirm(question) { diff --git a/src/cli/run-cli.mjs b/src/cli/run-cli.mjs index cf65e5f..f8d0704 100644 --- a/src/cli/run-cli.mjs +++ b/src/cli/run-cli.mjs @@ -45,7 +45,7 @@ function parseNumber(value, fallback = 0) { if (!text) { return fallback; } - const parsed = Number.parseFloat(text); + const parsed = Number(text); if (!Number.isFinite(parsed)) { throw new GenesisError("INPUT_INVALID", "Numeric input is invalid", { path: "/input", diff --git a/src/core/discovery-workflow.mjs b/src/core/discovery-workflow.mjs index 3be69d9..f7c4422 100644 --- a/src/core/discovery-workflow.mjs +++ b/src/core/discovery-workflow.mjs @@ -159,11 +159,13 @@ export function buildStatus({ decisionVersions = [], experimentVersions = [], ev const hasExperiment = experiment !== null; const experimentIsComplete = experimentCompletenessResult.complete; - const state = hasExperiment - ? (experiment?.status === "draft" && experimentIsComplete ? "approval_pending" : "discover") - : "discover"; + const state = !hasExperiment + ? "discover" + : experiment.status === "draft" + ? (experimentIsComplete ? "approval_pending" : "discover") + : experiment.status; const nextCommand = hasExperiment - ? (experimentIsComplete ? "status" : "plan-experiment") + ? "status" : (discoverGate.passed ? "plan-experiment" : "status"); return { diff --git a/src/core/metrics.mjs b/src/core/metrics.mjs index d363831..860a8d2 100644 --- a/src/core/metrics.mjs +++ b/src/core/metrics.mjs @@ -7,8 +7,15 @@ 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 ?? []), + ...WORKFLOW.preregistration_required_fields, ]); function toDate(value) { diff --git a/src/storage/projection.mjs b/src/storage/projection.mjs index 2f62e9f..25d7c28 100644 --- a/src/storage/projection.mjs +++ b/src/storage/projection.mjs @@ -131,13 +131,10 @@ function upsertOpportunity(db, values) { } function upsertRecordVersion(db, descriptor, record) { - db.prepare(` + 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 UPDATE SET - relative_path = excluded.relative_path, - updated_at = excluded.updated_at + ON CONFLICT(record_type, record_id, version) DO NOTHING `).run( descriptor.kind, descriptor.id, @@ -145,6 +142,7 @@ function upsertRecordVersion(db, descriptor, record) { descriptor.relativePath, record.updated_at ?? record.collected_at ?? record.created_at ?? new Date().toISOString(), ); + return result.changes === 1; } function recordKindForType(recordType) { @@ -247,7 +245,7 @@ export function openProjection(dbPath) { export function projectRecord(db, descriptor, record) { const transaction = db.transaction(() => { - upsertRecordVersion(db, descriptor, record); + const inserted = upsertRecordVersion(db, descriptor, record); if (descriptor.kind === "decision") { projectDecision(db, descriptor, record); @@ -255,7 +253,9 @@ export function projectRecord(db, descriptor, record) { } if (descriptor.kind === "evidence") { - projectEvidence(db, record); + if (inserted) { + projectEvidence(db, record); + } return; } @@ -292,9 +292,25 @@ export function readOpportunity(db, businessId) { export function projectionConsistency(db, descriptors) { const yamlCount = descriptors.length; - const projectedCount = db.prepare("SELECT COUNT(*) AS count FROM record_versions").get().count; + 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: yamlCount === projectedCount, + consistent: exactMatch, yamlCount, projectedCount, }; diff --git a/src/storage/workspace.mjs b/src/storage/workspace.mjs index f351b90..1221ffa 100644 --- a/src/storage/workspace.mjs +++ b/src/storage/workspace.mjs @@ -1,6 +1,7 @@ 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"; @@ -33,42 +34,129 @@ export function ensureWorkspace(projectRoot) { return paths; } -export async function withWorkspaceLock(projectRoot, operation) { - const paths = ensureWorkspace(projectRoot); - let handle; - let operationError; +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 { - handle = await fsp.open(paths.lock, "wx", 0o600); + 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 new GenesisError("WORKSPACE_LOCKED", "The Genesis workspace is already locked", { - path: "/workspace/lock", - correction: "Wait for the current workspace operation to finish before retrying", - escalation: "builder", - }); + 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(); - return await operation(); + result = await operation(); } catch (error) { operationError = error; - throw error; } finally { if (handle) { await handle.close().catch(() => {}); } + } - try { - await fsp.unlink(paths.lock); - } catch (error) { - if (!operationError && error?.code !== "ENOENT") { - throw error; - } + 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 index cee5f66..00689b0 100644 --- a/src/storage/yaml-record-store.mjs +++ b/src/storage/yaml-record-store.mjs @@ -37,31 +37,115 @@ function recordPath(projectRoot, kind, id, version) { return path.join(directory, `${id}.v${versionLabel}.yaml`); } -async function writeAtomicYaml(finalPath, value) { - const directory = path.dirname(finalPath); +async function writeStagedYaml(stagedPath, value) { const content = `${YAML.stringify(value)}\n`; - const tempPath = path.join( - directory, - `.${path.basename(finalPath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`, - ); - let handle; try { - handle = await fsp.open(tempPath, "wx", 0o600); + handle = await fsp.open(stagedPath, "wx", 0o600); await handle.writeFile(content); await handle.sync(); await handle.close(); handle = null; - await fsp.rename(tempPath, finalPath); } catch (error) { if (handle) { await handle.close().catch(() => {}); } - await fsp.unlink(tempPath).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; @@ -100,25 +184,108 @@ function walkRecords(directory, projectRoot, results) { } export async function writeRecord({ projectRoot, kind, id, version, value }) { - const finalPath = recordPath(projectRoot, kind, id, version); + const [saved] = await writeRecords({ + projectRoot, + records: [{ kind, id, version, value }], + }); + return saved; +} + +export async function writeRecords({ projectRoot, records }) { ensureWorkspace(projectRoot); - const directory = path.dirname(finalPath); - fs.mkdirSync(directory, { recursive: true, mode: 0o700 }); - fs.chmodSync(directory, 0o700); - - if (fs.existsSync(finalPath)) { - throw new GenesisError("RECORD_VERSION_EXISTS", "Record version already exists", { - path: finalPath, - correction: "Use the next version number for the record instead of overwriting an existing file", + 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", }); } - await writeAtomicYaml(finalPath, value); - return { - absolutePath: finalPath, - relativePath: path.relative(projectRoot, finalPath), + 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) { diff --git a/tests/cli-core.test.mjs b/tests/cli-core.test.mjs index 7d6449f..10cb9c3 100644 --- a/tests/cli-core.test.mjs +++ b/tests/cli-core.test.mjs @@ -1,5 +1,7 @@ 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"; @@ -25,3 +27,19 @@ test("suggestions are stable, offline, and immutable", () => { 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 index fcceff4..90d970c 100644 --- a/tests/cli-integration.test.mjs +++ b/tests/cli-integration.test.mjs @@ -258,3 +258,29 @@ test("CLI returns 2 for unknown commands and 1 for validation errors", { concurr 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/discovery-workflow.test.mjs b/tests/discovery-workflow.test.mjs index b54f8f1..b5d606b 100644 --- a/tests/discovery-workflow.test.mjs +++ b/tests/discovery-workflow.test.mjs @@ -310,9 +310,24 @@ test("buildStatus reports missing preregistration paths for incomplete drafts", }); assert.equal(status.state, "discover"); - assert.equal(status.next_command, "plan-experiment"); + 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/invariants.test.mjs b/tests/invariants.test.mjs index 42beaa2..dfa1168 100644 --- a/tests/invariants.test.mjs +++ b/tests/invariants.test.mjs @@ -411,6 +411,19 @@ test("Build requires passed validation or a bounded Human-approved learning prot }), [], ); + assert.equal( + transitionIssues(policySet, "business_lifecycle", "validate", "build", { + 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" }], diff --git a/tests/no-network.test.mjs b/tests/no-network.test.mjs index 835c863..eb898e8 100644 --- a/tests/no-network.test.mjs +++ b/tests/no-network.test.mjs @@ -1,5 +1,9 @@ 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"; @@ -8,10 +12,20 @@ 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", @@ -114,6 +128,46 @@ function collectSourceFiles(directory, results = []) { 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")), @@ -122,15 +176,15 @@ test("source tree has no network-capable imports or fetch calls", { concurrency: const violations = []; for (const filePath of sourceFiles) { - const text = fs.readFileSync(filePath, "utf8"); + const source = fs.readFileSync(filePath, "utf8"); + const specifiers = importedSpecifiers(source); for (const specifier of FORBIDDEN_IMPORTS) { - const importPattern = new RegExp(String.raw`(?:from|import\()\s*['"]${specifier.replaceAll("/", "\\/")}['"]`, "u"); - if (importPattern.test(text)) { + if (specifiers.has(specifier)) { violations.push(`${path.relative(ROOT, filePath)} imports ${specifier}`); } } - if (/\bfetch\s*\(/u.test(text)) { + if (/\bfetch\s*\(/u.test(source)) { violations.push(`${path.relative(ROOT, filePath)} references fetch(`); } } @@ -142,6 +196,7 @@ 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"); }; @@ -265,6 +320,7 @@ test("CLI flow completes without using fetch", { concurrency: false }, async () 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 index 356c95e..cb695de 100644 --- a/tests/projection.test.mjs +++ b/tests/projection.test.mjs @@ -178,6 +178,11 @@ test("projection captures latest paths, counts, and rebuilds deterministically", 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, @@ -202,6 +207,9 @@ test("projection captures latest paths, counts, and rebuilds deterministically", 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, diff --git a/tests/record-builders.test.mjs b/tests/record-builders.test.mjs index b829d0b..0cd0e68 100644 --- a/tests/record-builders.test.mjs +++ b/tests/record-builders.test.mjs @@ -121,6 +121,7 @@ test("builders produce schema-valid evidence and canonical records", async () => }); test("builders reject restricted evidence and schema-invalid records", () => { + const registry = createSchemaRegistry(ROOT); assert.throws( () => buildEvidenceEntry({ id: "bakery-ev-002", @@ -158,6 +159,15 @@ test("builders reject restricted evidence and schema-invalid records", () => { && 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", () => { diff --git a/tests/storage.test.mjs b/tests/storage.test.mjs index 3a6f22f..1313fce 100644 --- a/tests/storage.test.mjs +++ b/tests/storage.test.mjs @@ -8,7 +8,9 @@ import { ensureWorkspace, workspacePaths, withWorkspaceLock } from "../src/stora import { listRecords, readRecord, + recoverRecordTransactions, writeRecord, + writeRecords, } from "../src/storage/yaml-record-store.mjs"; function makeProjectRoot() { @@ -88,6 +90,87 @@ test("writeRecord rejects unsupported kinds", async () => { } }); +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 { @@ -119,6 +202,25 @@ test("withWorkspaceLock excludes competing locks and cleans up the lock file", a } }); +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 {