chati-dev 4.4.1 → 4.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/README.md +29 -27
  2. package/bin/chati.js +235 -1
  3. package/framework/agents/plan/tasks.md +46 -1
  4. package/framework/config.yaml +2 -2
  5. package/framework/constitution.md +13 -16
  6. package/framework/context/governance.md +5 -5
  7. package/framework/context/root.md +5 -5
  8. package/framework/manifest.json +13 -13
  9. package/framework/manifest.sig +1 -1
  10. package/framework/orchestrator/chati.md +43 -12
  11. package/node_modules/@chati/browser-capability/README.md +10 -0
  12. package/node_modules/@chati/browser-capability/package.json +17 -0
  13. package/node_modules/@chati/browser-capability/src/index.js +165 -0
  14. package/node_modules/@chati/core/package.json +13 -0
  15. package/node_modules/@chati/core/src/index.js +111 -0
  16. package/node_modules/@chati/knowledge-context/package.json +17 -0
  17. package/node_modules/@chati/knowledge-context/src/index.js +202 -0
  18. package/node_modules/@chati/planning/package.json +17 -0
  19. package/node_modules/@chati/planning/src/index.js +367 -0
  20. package/node_modules/@chati/provider-registry/package.json +16 -0
  21. package/node_modules/@chati/provider-registry/src/index.js +132 -0
  22. package/node_modules/@chati/rail/README.md +24 -0
  23. package/node_modules/@chati/rail/package.json +19 -0
  24. package/node_modules/@chati/rail/src/index.js +437 -0
  25. package/node_modules/@chati/release-lane/README.md +24 -0
  26. package/node_modules/@chati/release-lane/package.json +17 -0
  27. package/node_modules/@chati/release-lane/src/index.js +172 -0
  28. package/node_modules/@chati/review-council/package.json +17 -0
  29. package/node_modules/@chati/review-council/src/index.js +264 -0
  30. package/node_modules/@chati/tracking-clickup/README.md +55 -0
  31. package/node_modules/@chati/tracking-clickup/package.json +17 -0
  32. package/node_modules/@chati/tracking-clickup/src/index.js +293 -0
  33. package/package.json +25 -3
  34. package/src/config/ide-configs.js +11 -0
  35. package/src/context/domain-loader.js +1 -1
  36. package/src/dashboard/data-reader.js +1 -1
  37. package/src/executors/runner.js +1 -1
  38. package/src/installer/scaffold-applier.js +1 -1
  39. package/src/installer/templates.js +3 -3
  40. package/src/installer/validator.js +1 -1
  41. package/src/installer-v2/catalog-client.js +141 -0
  42. package/src/installer-v2/index.js +301 -0
  43. package/src/installer-v2/model-catalog-envelope.json +59 -0
  44. package/src/installer-v2/model-catalog.json +33 -0
  45. package/src/installer-v2/model-catalog.sig +1 -0
  46. package/src/installer-v2/wizard-installation.js +115 -0
  47. package/src/intelligence/registry-manager.js +1 -1
  48. package/src/license/client.js +1 -1
  49. package/src/memory/session-digest.js +1 -1
  50. package/src/merger/yaml-merger.js +1 -1
  51. package/src/orchestrator/browser-runtime.js +25 -0
  52. package/src/orchestrator/cli.js +93 -8
  53. package/src/orchestrator/clickup-projection.js +84 -0
  54. package/src/orchestrator/clickup-runtime.js +13 -0
  55. package/src/orchestrator/knowledge-runtime.js +64 -0
  56. package/src/orchestrator/planning-runtime.js +127 -0
  57. package/src/orchestrator/rail-runtime.js +421 -0
  58. package/src/orchestrator/release-runtime.js +14 -0
  59. package/src/orchestrator/review-runtime.js +74 -0
  60. package/src/orchestrator/runtime-installation-v2.js +38 -0
  61. package/src/orchestrator/session-manager.js +1 -1
  62. package/src/telemetry/config.js +1 -1
  63. package/src/terminal/adapters/grok-adapter.js +16 -0
  64. package/src/terminal/adapters/index.js +1 -0
  65. package/src/terminal/cli-registry.js +14 -0
  66. package/src/terminal/prompt-builder.js +2 -0
  67. package/src/terminal/run-agent.js +3 -0
  68. package/src/terminal/run-parallel.js +21 -10
  69. package/src/terminal/spawner.js +7 -1
  70. package/src/terminal/team-task-list.js +1 -1
  71. package/src/upgrade/checker.js +1 -1
  72. package/src/upgrade/migrator.js +1 -1
  73. package/src/wizard/i18n.js +8 -2
  74. package/src/wizard/index.js +39 -4
  75. package/src/wizard/questions.js +59 -2
@@ -0,0 +1,132 @@
1
+ import { ContractError, canonicalize } from '@chati/core';
2
+
3
+ const DEFAULT_BINDINGS = Object.freeze({ claude: 'anthropic', codex: 'openai', grok: 'xai' });
4
+ const ACTIONS = new Set(['discovery', 'planning', 'build', 'review', 'adjudication']);
5
+
6
+ function assertObject(value, code, label) {
7
+ if (!value || typeof value !== 'object' || Array.isArray(value)) throw new ContractError(code, `${label} must be an object`);
8
+ }
9
+
10
+ function assertString(value, code, label) {
11
+ if (typeof value !== 'string' || value.trim() === '') throw new ContractError(code, `${label} must be a non-empty string`);
12
+ }
13
+
14
+ function parseTime(value, code, label) {
15
+ assertString(value, code, label);
16
+ const parsed = Date.parse(value);
17
+ if (Number.isNaN(parsed)) throw new ContractError(code, `${label} must be a valid timestamp`);
18
+ return parsed;
19
+ }
20
+
21
+ function readClock(clock) {
22
+ if (typeof clock !== 'function') throw new ContractError('MISSING_CLOCK', 'a deterministic clock function is required');
23
+ const value = clock();
24
+ const millis = value instanceof Date ? value.getTime() : Date.parse(value);
25
+ if (Number.isNaN(millis)) throw new ContractError('INVALID_CLOCK', 'clock must return a valid Date or RFC3339 timestamp');
26
+ return millis;
27
+ }
28
+
29
+ /** Validates the immutable model catalog used by a single installation. */
30
+ export function validateCapabilitySnapshot(snapshot, { clock } = {}) {
31
+ assertObject(snapshot, 'INVALID_CATALOG', 'snapshot');
32
+ assertString(snapshot.snapshot_id, 'INVALID_CATALOG', 'snapshot_id');
33
+ const expiresAt = parseTime(snapshot.expires_at, 'INVALID_CATALOG', 'expires_at');
34
+ if (expiresAt <= readClock(clock)) throw new ContractError('CATALOG_SNAPSHOT_EXPIRED', 'capability snapshot is expired', { snapshot_id: snapshot.snapshot_id });
35
+ if (!Array.isArray(snapshot.models) || snapshot.models.length === 0) throw new ContractError('INVALID_CATALOG', 'models must be a non-empty array');
36
+ const ids = new Set();
37
+ for (const model of snapshot.models) {
38
+ assertObject(model, 'INVALID_CATALOG', 'model');
39
+ assertString(model.provider_id, 'INVALID_CATALOG', 'model.provider_id');
40
+ assertString(model.model_id, 'INVALID_CATALOG', 'model.model_id');
41
+ const key = `${model.provider_id}:${model.model_id}`;
42
+ if (ids.has(key)) throw new ContractError('INVALID_CATALOG', `duplicate model ${key}`);
43
+ ids.add(key);
44
+ if (!Array.isArray(model.actions) || model.actions.length === 0 || !model.actions.every((action) => ACTIONS.has(action))) {
45
+ throw new ContractError('INVALID_CATALOG', 'model.actions contains an unsupported action');
46
+ }
47
+ if (model.actions.includes('adjudication')) {
48
+ assertString(model.highest_reasoning_configuration, 'INVALID_CATALOG', 'model.highest_reasoning_configuration');
49
+ } else if (model.highest_reasoning_configuration !== undefined) {
50
+ assertString(model.highest_reasoning_configuration, 'INVALID_CATALOG', 'model.highest_reasoning_configuration');
51
+ }
52
+ }
53
+ return Object.freeze(canonicalize(snapshot));
54
+ }
55
+
56
+ /** C-01. The installed binding set is an absolute allowlist. */
57
+ export function validateInstallation(installation, snapshot, { bindingMap = DEFAULT_BINDINGS, clock } = {}) {
58
+ assertObject(installation, 'INVALID_INSTALLATION', 'installation');
59
+ for (const field of ['installation_id', 'profile', 'capability_snapshot_ref', 'policy_ref']) assertString(installation[field], 'INVALID_INSTALLATION', field);
60
+ const catalog = validateCapabilitySnapshot(snapshot, { clock });
61
+ if (installation.capability_snapshot_ref !== catalog.snapshot_id) {
62
+ throw new ContractError('CATALOG_SNAPSHOT_MISMATCH', 'installation does not reference the supplied catalog');
63
+ }
64
+ if (!Array.isArray(installation.enabled_providers) || installation.enabled_providers.length === 0) {
65
+ throw new ContractError('INVALID_INSTALLATION', 'enabled_providers must be a non-empty array');
66
+ }
67
+ const selectedHarnesses = new Set();
68
+ const selectedBindings = new Map();
69
+ for (const binding of installation.enabled_providers) {
70
+ assertObject(binding, 'INVALID_INSTALLATION', 'enabled provider binding');
71
+ assertString(binding.provider_id, 'INVALID_INSTALLATION', 'provider_id');
72
+ assertString(binding.harness_id, 'INVALID_INSTALLATION', 'harness_id');
73
+ if (selectedHarnesses.has(binding.harness_id)) throw new ContractError('DUPLICATE_HARNESS_BINDING', `harness ${binding.harness_id} has more than one selected provider`);
74
+ if (bindingMap[binding.harness_id] !== binding.provider_id) {
75
+ throw new ContractError('PROVIDER_HARNESS_MISMATCH', `${binding.provider_id}/${binding.harness_id} is not an allowed binding`);
76
+ }
77
+ if (!Array.isArray(binding.allowed_models) || binding.allowed_models.length === 0 || !binding.allowed_models.every((model) => typeof model === 'string' && model)) {
78
+ throw new ContractError('INVALID_INSTALLATION', 'allowed_models must be a non-empty array');
79
+ }
80
+ for (const modelId of binding.allowed_models) {
81
+ const catalogModel = catalog.models.find((model) => model.provider_id === binding.provider_id && model.model_id === modelId);
82
+ if (!catalogModel) {
83
+ throw new ContractError('MODEL_NOT_IN_CATALOG', `${modelId} is not available for ${binding.provider_id}`);
84
+ }
85
+ }
86
+ if (binding.allowed_reasoning_configurations !== undefined) {
87
+ if (!Array.isArray(binding.allowed_reasoning_configurations) || !binding.allowed_reasoning_configurations.every((reasoning) => typeof reasoning === 'string' && reasoning)) {
88
+ throw new ContractError('INVALID_INSTALLATION', 'allowed_reasoning_configurations must be an array of non-empty strings');
89
+ }
90
+ }
91
+ selectedHarnesses.add(binding.harness_id);
92
+ selectedBindings.set(`${binding.provider_id}:${binding.harness_id}`, binding);
93
+ }
94
+ assertObject(installation.primary_binding, 'INVALID_INSTALLATION', 'primary_binding');
95
+ const primaryKey = `${installation.primary_binding.provider_id}:${installation.primary_binding.harness_id}`;
96
+ if (!selectedBindings.has(primaryKey)) throw new ContractError('PRIMARY_BINDING_NOT_SELECTED', 'primary_binding must be selected');
97
+ if (installation.primary_harness !== undefined && installation.primary_harness !== installation.primary_binding.harness_id) {
98
+ throw new ContractError('PRIMARY_HARNESS_MISMATCH', 'primary_harness must match primary_binding.harness_id');
99
+ }
100
+ if (installation.profile === 'focus-ai-internal' && installation.external_integrations?.clickup !== 'required') {
101
+ throw new ContractError('PROFILE_DEPENDENCY_MISSING', 'focus-ai-internal requires ClickUp');
102
+ }
103
+ return Object.freeze(canonicalize(installation));
104
+ }
105
+
106
+ /** C-02. Returns only an explicitly selected and catalog-eligible binding. */
107
+ export function assertEligibleInvocation({ installation, snapshot, invocation, clock, bindingMap = DEFAULT_BINDINGS }) {
108
+ const selected = validateInstallation(installation, snapshot, { bindingMap, clock });
109
+ assertObject(invocation, 'INVALID_INVOCATION', 'invocation');
110
+ assertString(invocation.provider_id, 'INVALID_INVOCATION', 'provider_id');
111
+ assertString(invocation.harness_id, 'INVALID_INVOCATION', 'harness_id');
112
+ assertString(invocation.action, 'INVALID_INVOCATION', 'action');
113
+ assertString(invocation.model_pin?.model_id, 'INVALID_INVOCATION', 'model_pin.model_id');
114
+ if (invocation.model_pin?.catalog_snapshot_ref !== selected.capability_snapshot_ref) {
115
+ throw new ContractError('CATALOG_SNAPSHOT_MISMATCH', 'invocation pin does not match installation snapshot');
116
+ }
117
+ if (!snapshot.models.some((candidate) => candidate.provider_id === invocation.provider_id)) {
118
+ throw new ContractError('UNKNOWN_PROVIDER', 'provider is absent from the selected capability snapshot');
119
+ }
120
+ const binding = selected.enabled_providers.find((candidate) => candidate.provider_id === invocation.provider_id && candidate.harness_id === invocation.harness_id);
121
+ if (!binding) throw new ContractError('BINDING_NOT_SELECTED', 'provider/harness binding was not selected');
122
+ if (!binding.allowed_models.includes(invocation.model_pin.model_id)) throw new ContractError('MODEL_NOT_SELECTED', 'model was not selected for this binding');
123
+ const reasoning = invocation.model_pin.reasoning_configuration;
124
+ if (reasoning !== undefined && reasoning !== null && !binding.allowed_reasoning_configurations?.includes(reasoning)) {
125
+ throw new ContractError('REASONING_NOT_SELECTED', 'reasoning configuration was not selected for this binding');
126
+ }
127
+ const model = snapshot.models.find((candidate) => candidate.provider_id === invocation.provider_id && candidate.model_id === invocation.model_pin.model_id);
128
+ if (!model || !model.actions.includes(invocation.action)) throw new ContractError('MODEL_NOT_ELIGIBLE', 'model is not eligible for this action');
129
+ return Object.freeze({ binding: canonicalize(binding), model: canonicalize(model) });
130
+ }
131
+
132
+ export const knownProviderHarnessBindings = DEFAULT_BINDINGS;
@@ -0,0 +1,24 @@
1
+ # @chati/rail
2
+
3
+ Local, deterministic implementation of G3 contracts C-04 and C-06.
4
+
5
+ It accepts only sealed handoffs from `@chati/planning`, writes an append-only
6
+ JSONL journal, a local exclusive journal lock, and a single task-claim state
7
+ machine for normal dispatch and recovery. Every record is hash-chained and a
8
+ sidecar checkpoint detects tail truncation. Completion is based only on local
9
+ Git and injected fake-CI evidence. It has no provider invocation, remote Git,
10
+ ClickUp, Brain, browser, release, timer, or network integration.
11
+
12
+ ## Contract limits
13
+
14
+ - A lease expiry never authorizes blind takeover. Recovery requires structured
15
+ process, heartbeat, Git, CI, and every-effect reconciliation.
16
+ - Side effects require an idempotency key persisted in the journal before a
17
+ receipt may be recorded, and require a live lease.
18
+ - `ready_to_merge` and its explicit `merged` transition require independent
19
+ review references, acceptance evidence, local Git commit verification, and
20
+ passed CI evidence.
21
+ - Completion records are immutable. A later `merged` record is a new event,
22
+ not an edit of `ready_to_merge`, and cannot skip it. Completion requires a
23
+ live lease. ClickUp and any external projection are
24
+ intentionally outside this package.
@@ -0,0 +1,19 @@
1
+ {
2
+ "name": "@chati/rail",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "description": "Local contract engine for sealed CHATI task execution",
6
+ "type": "module",
7
+ "exports": "./src/index.js",
8
+ "dependencies": {
9
+ "@chati/core": "0.1.0",
10
+ "@chati/planning": "0.1.0",
11
+ "@chati/review-council": "0.1.0"
12
+ },
13
+ "scripts": {
14
+ "test": "node --test test/**/*.test.js"
15
+ },
16
+ "engines": {
17
+ "node": ">=20.0.0"
18
+ }
19
+ }
@@ -0,0 +1,437 @@
1
+ import { appendFileSync, closeSync, existsSync, mkdirSync, openSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
2
+ import { dirname } from 'node:path';
3
+ import { ContractError, canonicalize, sha256 } from '@chati/core';
4
+ import { createAttemptBinding, validateAttemptBinding, verifyLocalGitCommit } from '@chati/planning';
5
+ import { acceptReview, decideReviewProgress } from '@chati/review-council';
6
+
7
+ const RFC3339 = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
8
+ const TASK_CLOSED_STATES = new Set(['merged', 'blocked', 'cancelled']);
9
+ const EXECUTABLE_ATTEMPT_STATE = 'active';
10
+ const REVIEW_DECISIONS = new Set(['accept', 'normal-rework', 'AI_ADJUDICATION', 'blocked', 'required-human-decision']);
11
+ const LOCK_RETRIES = 50;
12
+ const LOCK_WAIT = new Int32Array(new SharedArrayBuffer(4));
13
+
14
+ function fail(code, message, details = {}) {
15
+ throw new ContractError(code, message, details);
16
+ }
17
+
18
+ function assertObject(value, code, label) {
19
+ if (!value || typeof value !== 'object' || Array.isArray(value)) fail(code, `${label} must be an object`);
20
+ }
21
+
22
+ function assertString(value, code, label) {
23
+ if (typeof value !== 'string' || value.trim() === '') fail(code, `${label} must be a non-empty string`);
24
+ }
25
+
26
+ function assertStringArray(value, code, label, { allowEmpty = true } = {}) {
27
+ if (!Array.isArray(value) || (!allowEmpty && value.length === 0) || !value.every((item) => typeof item === 'string' && item.trim() !== '')) {
28
+ fail(code, `${label} must be an array of non-empty strings`);
29
+ }
30
+ }
31
+
32
+ function timestamp(clock) {
33
+ if (typeof clock !== 'function') fail('MISSING_CLOCK', 'a deterministic clock function is required');
34
+ const value = clock();
35
+ const iso = value instanceof Date ? value.toISOString() : value;
36
+ if (typeof iso !== 'string' || !RFC3339.test(iso) || Number.isNaN(Date.parse(iso))) fail('INVALID_CLOCK', 'clock must return a valid RFC3339 timestamp or Date');
37
+ return iso;
38
+ }
39
+
40
+ function plusMs(iso, milliseconds) {
41
+ if (!Number.isInteger(milliseconds) || milliseconds <= 0) fail('INVALID_LEASE_TTL', 'lease_ttl_ms must be a positive integer');
42
+ return new Date(Date.parse(iso) + milliseconds).toISOString();
43
+ }
44
+
45
+ function stableEventId(sequence, event) {
46
+ const material = event.causation_id === undefined
47
+ ? { sequence, event: { type: event.type, payload: event.payload } }
48
+ : { sequence, event };
49
+ return `rail-${String(sequence).padStart(8, '0')}-${sha256(material).slice(0, 16)}`;
50
+ }
51
+
52
+ function checkpointPath(journalPath) { return `${journalPath}.checkpoint`; }
53
+ function lockPath(journalPath) { return `${journalPath}.lock`; }
54
+
55
+ function parseJournal(journalPath) {
56
+ if (!existsSync(journalPath)) return [];
57
+ const lines = readFileSync(journalPath, 'utf8').split('\n').filter(Boolean);
58
+ const records = [];
59
+ let previous_hash = 'GENESIS';
60
+ for (const [index, line] of lines.entries()) {
61
+ let record;
62
+ try { record = JSON.parse(line); } catch { fail('JOURNAL_CORRUPT', 'journal contains malformed JSONL', { line: index + 1 }); }
63
+ assertObject(record, 'JOURNAL_CORRUPT', 'journal record');
64
+ const { record_hash, chain_hash, previous_hash: record_previous_hash, ...material } = record;
65
+ assertString(record_hash, 'JOURNAL_CORRUPT', 'record_hash');
66
+ assertString(chain_hash, 'JOURNAL_CORRUPT', 'chain_hash');
67
+ if (record_previous_hash !== previous_hash) fail('JOURNAL_CHAIN_MISMATCH', 'journal previous hash does not match prior record', { event_id: record.event_id });
68
+ if (sha256(material) !== record_hash) fail('JOURNAL_INTEGRITY_MISMATCH', 'journal record hash does not match material', { event_id: record.event_id });
69
+ if (sha256({ previous_hash, record_hash }) !== chain_hash) fail('JOURNAL_CHAIN_MISMATCH', 'journal chain hash does not match record', { event_id: record.event_id });
70
+ previous_hash = chain_hash;
71
+ records.push(Object.freeze(canonicalize(record)));
72
+ }
73
+ const checkpointFile = checkpointPath(journalPath);
74
+ if (existsSync(checkpointFile)) {
75
+ let checkpoint;
76
+ try { checkpoint = JSON.parse(readFileSync(checkpointFile, 'utf8')); } catch { fail('JOURNAL_CHECKPOINT_CORRUPT', 'journal checkpoint is malformed'); }
77
+ assertObject(checkpoint, 'JOURNAL_CHECKPOINT_CORRUPT', 'journal checkpoint');
78
+ if (checkpoint.schema_version !== 1 || checkpoint.record_count !== records.length || checkpoint.chain_hash !== previous_hash || checkpoint.last_event_id !== records.at(-1)?.event_id) {
79
+ fail('JOURNAL_TRUNCATION_DETECTED', 'journal does not match its persisted checkpoint');
80
+ }
81
+ }
82
+ return records;
83
+ }
84
+
85
+ function isExpired(claim, now) {
86
+ return Date.parse(claim.lease.expires_at) <= Date.parse(now);
87
+ }
88
+
89
+ function validateRecoveryReconciliation(reconciliation, attempt, effects, gitDir) {
90
+ assertObject(reconciliation, 'INVALID_RECOVERY', 'reconciliation');
91
+ if (reconciliation.status !== 'safe_to_resume') fail('RECOVERY_RECONCILIATION_REQUIRED', 'recovery requires safe_to_resume reconciliation');
92
+ for (const field of ['process', 'heartbeat', 'git', 'ci']) assertObject(reconciliation[field], 'RECOVERY_RECONCILIATION_REQUIRED', `reconciliation.${field}`);
93
+ if (reconciliation.process.status !== 'not_running') fail('RECOVERY_PROCESS_UNRESOLVED', 'recovery requires an observed non-running prior process');
94
+ assertStringArray(reconciliation.process.evidence_refs, 'RECOVERY_PROCESS_UNRESOLVED', 'reconciliation.process.evidence_refs', { allowEmpty: false });
95
+ if (reconciliation.heartbeat.status !== 'expired') fail('RECOVERY_HEARTBEAT_UNRESOLVED', 'recovery requires an expired heartbeat observation');
96
+ assertString(reconciliation.heartbeat.observed_at, 'RECOVERY_HEARTBEAT_UNRESOLVED', 'reconciliation.heartbeat.observed_at');
97
+ assertStringArray(reconciliation.heartbeat.evidence_refs, 'RECOVERY_HEARTBEAT_UNRESOLVED', 'reconciliation.heartbeat.evidence_refs', { allowEmpty: false });
98
+ if (reconciliation.git.status !== 'matched' || reconciliation.git.commit_ref !== attempt.git_commit_ref) fail('RECOVERY_GIT_UNRESOLVED', 'recovery Git reconciliation must match the bound commit');
99
+ assertStringArray(reconciliation.git.evidence_refs, 'RECOVERY_GIT_UNRESOLVED', 'reconciliation.git.evidence_refs', { allowEmpty: false });
100
+ verifyLocalGitCommit({ git_dir: gitDir, git_commit_ref: attempt.git_commit_ref });
101
+ if (!['not_started', 'passed', 'failed'].includes(reconciliation.ci.status)) fail('RECOVERY_CI_UNRESOLVED', 'recovery CI status must be explicit');
102
+ assertStringArray(reconciliation.ci.evidence_refs, 'RECOVERY_CI_UNRESOLVED', 'reconciliation.ci.evidence_refs', { allowEmpty: false });
103
+ if (!Array.isArray(reconciliation.effects)) fail('RECOVERY_EFFECTS_UNRESOLVED', 'recovery requires an effect reconciliation array');
104
+ const provided = new Map(reconciliation.effects.map((effect) => [effect?.idempotency_key, effect]));
105
+ for (const effect of effects) {
106
+ const item = provided.get(effect.idempotency_key);
107
+ if (!item || !['receipted', 'not_sent'].includes(item.status)) fail('RECOVERY_EFFECTS_UNRESOLVED', 'every prepared effect requires a resolved outcome', { idempotency_key: effect.idempotency_key });
108
+ if (effect.receipt_ref && (item.status !== 'receipted' || item.receipt_ref !== effect.receipt_ref)) fail('RECOVERY_EFFECTS_UNRESOLVED', 'receipt reconciliation does not match journal', { idempotency_key: effect.idempotency_key });
109
+ }
110
+ }
111
+
112
+ /**
113
+ * C-04/C-06 local engine. Every mutable operational fact is represented by a
114
+ * new journal event. The journal itself is canonical for this package.
115
+ */
116
+ export class RailEngine {
117
+ #journalPath;
118
+ #clock;
119
+ #records;
120
+
121
+ constructor({ journal_path, clock } = {}) {
122
+ assertString(journal_path, 'INVALID_JOURNAL_PATH', 'journal_path');
123
+ this.#journalPath = journal_path;
124
+ this.#clock = clock;
125
+ this.#records = parseJournal(journal_path);
126
+ }
127
+
128
+ get journalPath() { return this.#journalPath; }
129
+ get records() { return Object.freeze([...this.#records]); }
130
+
131
+ #exclusive(action) {
132
+ mkdirSync(dirname(this.#journalPath), { recursive: true });
133
+ const path = lockPath(this.#journalPath);
134
+ let descriptor;
135
+ for (let retry = 0; retry < LOCK_RETRIES; retry += 1) {
136
+ try { descriptor = openSync(path, 'wx', 0o600); break; } catch (error) {
137
+ if (error?.code !== 'EEXIST') throw error;
138
+ // Wait briefly for the other local transaction, never steal its lock.
139
+ Atomics.wait(LOCK_WAIT, 0, 0, 2);
140
+ }
141
+ }
142
+ if (descriptor === undefined) fail('JOURNAL_LOCK_BUSY', 'journal mutation lock is already held');
143
+ try {
144
+ this.#records = parseJournal(this.#journalPath);
145
+ return action();
146
+ } finally {
147
+ closeSync(descriptor);
148
+ unlinkSync(path);
149
+ }
150
+ }
151
+
152
+ #assertLiveLease(attempt, now, code = 'LEASE_EXPIRED') {
153
+ if (isExpired(attempt.claim, now)) fail(code, 'operation requires a live claim lease', { attempt_id: attempt.attempt_id });
154
+ }
155
+
156
+ #writeCheckpoint(record) {
157
+ const checkpoint = canonicalize({ schema_version: 1, record_count: this.#records.length, last_event_id: record.event_id, chain_hash: record.chain_hash });
158
+ const target = checkpointPath(this.#journalPath);
159
+ const temporary = `${target}.${process.pid}.tmp`;
160
+ writeFileSync(temporary, JSON.stringify(checkpoint), { encoding: 'utf8', mode: 0o600 });
161
+ renameSync(temporary, target);
162
+ }
163
+
164
+ #append(type, payload, causation_id) {
165
+ const occurred_at = timestamp(this.#clock);
166
+ const material = canonicalize({
167
+ schema_version: 1,
168
+ event_id: stableEventId(this.#records.length + 1, { type, payload, causation_id }),
169
+ type,
170
+ occurred_at,
171
+ ...(causation_id ? { causation_id } : {}),
172
+ payload,
173
+ });
174
+ const record_hash = sha256(material);
175
+ const previous_hash = this.#records.at(-1)?.chain_hash ?? 'GENESIS';
176
+ const record = Object.freeze(canonicalize({ ...material, record_hash, previous_hash, chain_hash: sha256({ previous_hash, record_hash }) }));
177
+ mkdirSync(dirname(this.#journalPath), { recursive: true });
178
+ appendFileSync(this.#journalPath, `${JSON.stringify(record)}\n`, { encoding: 'utf8', flag: 'a' });
179
+ this.#records.push(record);
180
+ this.#writeCheckpoint(record);
181
+ return record;
182
+ }
183
+
184
+ #attempts() {
185
+ const attempts = new Map();
186
+ for (const record of this.#records) {
187
+ const { type, payload } = record;
188
+ if (type === 'attempt_claimed') attempts.set(payload.attempt.attempt_id, { ...payload.attempt, claim: payload.claim, state: 'active', claim_event_id: record.event_id });
189
+ if (type === 'attempt_heartbeat') {
190
+ const current = attempts.get(payload.attempt_id);
191
+ if (current) current.claim = { ...current.claim, lease: payload.lease };
192
+ }
193
+ if (type === 'attempt_recovered') {
194
+ const current = attempts.get(payload.attempt_id);
195
+ if (current) { current.claim = payload.claim; current.state = payload.resume_state ?? 'active'; current.recovery_event_id = record.event_id; }
196
+ }
197
+ if (type === 'attempt_completed') {
198
+ const current = attempts.get(payload.completion.attempt_id);
199
+ if (current) current.state = payload.completion.state;
200
+ }
201
+ if (type === 'attempt_blocked' || type === 'attempt_cancelled') {
202
+ const current = attempts.get(payload.attempt_id);
203
+ if (current) current.state = type === 'attempt_blocked' ? 'blocked' : 'cancelled';
204
+ }
205
+ }
206
+ return attempts;
207
+ }
208
+
209
+ #effects() {
210
+ const effects = new Map();
211
+ for (const record of this.#records) {
212
+ if (record.type === 'effect_prepared') effects.set(record.payload.idempotency_key, { ...record.payload, prepared_event_id: record.event_id });
213
+ if (record.type === 'effect_receipted') {
214
+ const effect = effects.get(record.payload.idempotency_key);
215
+ if (effect) effect.receipt_ref = record.payload.receipt_ref;
216
+ }
217
+ }
218
+ return effects;
219
+ }
220
+
221
+ #currentClaimForTask(taskId) {
222
+ const now = timestamp(this.#clock);
223
+ for (const attempt of this.#attempts().values()) {
224
+ if (attempt.task_id === taskId && !TASK_CLOSED_STATES.has(attempt.state)) return { attempt, expired: isExpired(attempt.claim, now) };
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ #mergedTaskIdsForHandoff(handoffId) {
230
+ return [...this.#attempts().values()]
231
+ .filter((attempt) => attempt.handoff_id === handoffId && attempt.state === 'merged')
232
+ .map((attempt) => attempt.task_id);
233
+ }
234
+
235
+ #assertAcceptedReviews(attemptId, reviews) {
236
+ const decisions = this.#records.filter((record) => record.type === 'review_decided'
237
+ && record.payload?.attempt_id === attemptId);
238
+ const byReference = new Map(decisions.map((record) => [record.payload.review_ref, record]));
239
+ const uniqueReviews = new Set(reviews);
240
+ if (uniqueReviews.size !== reviews.length || reviews.some((reviewRef) => !byReference.has(reviewRef))) {
241
+ fail('UNVERIFIED_REVIEW_EVIDENCE', 'every completion review must reference a canonical journal decision', { attempt_id: attemptId });
242
+ }
243
+ const latest = decisions.at(-1);
244
+ if (!latest || latest.payload.decision?.decision !== 'accept' || !uniqueReviews.has(latest.payload.review_ref)) {
245
+ fail('REVIEW_NOT_ACCEPTED', 'completion requires the latest canonical review decision to accept the attempt', { attempt_id: attemptId });
246
+ }
247
+ }
248
+
249
+ claimTask({ handoff, task_id, attempt_id, operator_id, harness_process_id, git_dir, lease_ttl_ms = 300000 } = {}) {
250
+ return this.#exclusive(() => {
251
+ assertString(operator_id, 'INVALID_CLAIM', 'operator_id');
252
+ assertString(harness_process_id, 'INVALID_CLAIM', 'harness_process_id');
253
+ const incumbent = this.#currentClaimForTask(task_id);
254
+ if (incumbent) {
255
+ const code = incumbent.expired ? 'CLAIM_RECOVERY_REQUIRED' : 'TASK_ALREADY_CLAIMED';
256
+ fail(code, 'task has an existing claim and cannot be taken over without recovery', { task_id, attempt_id: incumbent.attempt.attempt_id });
257
+ }
258
+ if (this.#attempts().has(attempt_id)) fail('ATTEMPT_ALREADY_EXISTS', 'attempt_id already exists in journal', { attempt_id });
259
+ const completed_task_ids = this.#mergedTaskIdsForHandoff(handoff.handoff_id);
260
+ const attempt = createAttemptBinding({ handoff, task_id, attempt_id, completed_task_ids, git_dir });
261
+ const acquired_at = timestamp(this.#clock);
262
+ const claim = canonicalize({
263
+ claim_id: `claim-${sha256({ attempt_id, acquired_at, operator_id, harness_process_id }).slice(0, 24)}`,
264
+ task_id,
265
+ handoff_id: attempt.handoff_id,
266
+ attempt_id,
267
+ operator_id,
268
+ harness_process_id,
269
+ lease: { acquired_at, expires_at: plusMs(acquired_at, lease_ttl_ms), heartbeat_at: acquired_at },
270
+ idempotency_scope: `task:${task_id}:attempt:${attempt_id}`,
271
+ });
272
+ const record = this.#append('attempt_claimed', { attempt, claim });
273
+ return Object.freeze(canonicalize({ attempt, claim, event_id: record.event_id }));
274
+ });
275
+ }
276
+
277
+ heartbeat({ attempt_id, claim_id, operator_id, lease_ttl_ms = 300000 } = {}) {
278
+ return this.#exclusive(() => {
279
+ assertString(attempt_id, 'INVALID_HEARTBEAT', 'attempt_id');
280
+ assertString(claim_id, 'INVALID_HEARTBEAT', 'claim_id');
281
+ assertString(operator_id, 'INVALID_HEARTBEAT', 'operator_id');
282
+ const attempt = this.#attempts().get(attempt_id);
283
+ if (!attempt || ![EXECUTABLE_ATTEMPT_STATE, 'ready_to_merge'].includes(attempt.state)) fail('ATTEMPT_NOT_ACTIVE', 'attempt is not active', { attempt_id });
284
+ if (attempt.claim.claim_id !== claim_id || attempt.claim.operator_id !== operator_id) fail('CLAIM_OWNER_MISMATCH', 'only the active claim owner may heartbeat', { attempt_id });
285
+ const heartbeat_at = timestamp(this.#clock);
286
+ this.#assertLiveLease(attempt, heartbeat_at);
287
+ const lease = { ...attempt.claim.lease, heartbeat_at, expires_at: plusMs(heartbeat_at, lease_ttl_ms) };
288
+ const record = this.#append('attempt_heartbeat', { attempt_id, claim_id, lease }, attempt.claim_event_id);
289
+ return Object.freeze(canonicalize({ attempt_id, claim_id, lease, event_id: record.event_id }));
290
+ });
291
+ }
292
+
293
+ prepareEffect({ attempt_id, claim_id, side_effect_class, idempotency_key, effect_ref } = {}) {
294
+ return this.#exclusive(() => {
295
+ for (const [field, value] of Object.entries({ attempt_id, claim_id, side_effect_class, idempotency_key, effect_ref })) assertString(value, 'INVALID_EFFECT', field);
296
+ const attempt = this.#attempts().get(attempt_id);
297
+ if (!attempt || attempt.state !== EXECUTABLE_ATTEMPT_STATE) fail('ATTEMPT_NOT_ACTIVE', 'effect requires an active attempt', { attempt_id });
298
+ if (attempt.claim.claim_id !== claim_id) fail('CLAIM_OWNER_MISMATCH', 'effect does not belong to active claim', { attempt_id });
299
+ this.#assertLiveLease(attempt, timestamp(this.#clock));
300
+ const existing = this.#effects().get(idempotency_key);
301
+ if (existing) {
302
+ if (existing.attempt_id !== attempt_id || existing.side_effect_class !== side_effect_class || existing.effect_ref !== effect_ref) {
303
+ fail('IDEMPOTENCY_KEY_CONFLICT', 'idempotency key is already bound to different effect material', { idempotency_key });
304
+ }
305
+ return Object.freeze(canonicalize({ ...existing, replay: true }));
306
+ }
307
+ const record = this.#append('effect_prepared', { attempt_id, claim_id, side_effect_class, idempotency_key, effect_ref });
308
+ return Object.freeze(canonicalize({ attempt_id, claim_id, side_effect_class, idempotency_key, effect_ref, prepared_event_id: record.event_id, replay: false }));
309
+ });
310
+ }
311
+
312
+ recordEffectReceipt({ idempotency_key, receipt_ref } = {}) {
313
+ return this.#exclusive(() => {
314
+ assertString(idempotency_key, 'INVALID_EFFECT_RECEIPT', 'idempotency_key');
315
+ assertString(receipt_ref, 'INVALID_EFFECT_RECEIPT', 'receipt_ref');
316
+ const effect = this.#effects().get(idempotency_key);
317
+ if (!effect) fail('EFFECT_NOT_PREPARED', 'receipt requires a persisted prepared effect', { idempotency_key });
318
+ if (effect.receipt_ref) {
319
+ if (effect.receipt_ref !== receipt_ref) fail('EFFECT_RECEIPT_CONFLICT', 'effect receipt cannot be changed', { idempotency_key });
320
+ return Object.freeze(canonicalize({ ...effect, replay: true }));
321
+ }
322
+ const record = this.#append('effect_receipted', { idempotency_key, receipt_ref }, effect.prepared_event_id);
323
+ return Object.freeze(canonicalize({ ...effect, receipt_ref, receipt_event_id: record.event_id, replay: false }));
324
+ });
325
+ }
326
+
327
+ /**
328
+ * Persists the Review Council transition that controls the next RAIL step.
329
+ * Review evidence itself remains external to this package, but the immutable
330
+ * reference and its selected decision become part of the canonical journal.
331
+ */
332
+ recordReviewDecision({ attempt_id, review_ref, decision, review, invocation, prior_review_evidence = [], installation, snapshot } = {}) {
333
+ return this.#exclusive(() => {
334
+ assertString(attempt_id, 'INVALID_REVIEW_DECISION', 'attempt_id');
335
+ assertString(review_ref, 'INVALID_REVIEW_DECISION', 'review_ref');
336
+ assertObject(decision, 'INVALID_REVIEW_DECISION', 'decision');
337
+ if (!REVIEW_DECISIONS.has(decision.decision)) fail('INVALID_REVIEW_DECISION', 'decision is not a Review Council outcome');
338
+ const attempt = this.#attempts().get(attempt_id);
339
+ if (!attempt || attempt.state !== EXECUTABLE_ATTEMPT_STATE) fail('ATTEMPT_NOT_ACTIVE', 'review decision requires an active attempt', { attempt_id });
340
+ this.#assertLiveLease(attempt, timestamp(this.#clock));
341
+ const validated = acceptReview({ review, invocation, installation, snapshot, clock: this.#clock });
342
+ if (validated.attempt_id !== attempt_id) fail('REVIEW_ATTEMPT_MISMATCH', 'review is not bound to this RAIL attempt');
343
+ const expectedRef = `rail-review://${attempt.handoff_id}/${attempt_id}/${validated.review_id}`;
344
+ if (review_ref !== expectedRef) fail('REVIEW_REFERENCE_MISMATCH', 'review reference is not bound to the claimed handoff and attempt');
345
+ const expected = decideReviewProgress({ prior_review_evidence, current_review: validated, reviewer_invocation: invocation, installation, snapshot, clock: this.#clock });
346
+ if (sha256(expected) !== sha256(decision)) fail('REVIEW_DECISION_MISMATCH', 'journal decision does not match validated Review Council evidence');
347
+ const existing = this.#records.find((record) => record.type === 'review_decided' && record.payload?.review_ref === review_ref);
348
+ if (existing) {
349
+ if (existing.payload.attempt_id !== attempt_id || sha256(existing.payload.decision) !== sha256(decision)) {
350
+ fail('REVIEW_DECISION_CONFLICT', 'review reference is already bound to different decision material', { review_ref });
351
+ }
352
+ return Object.freeze(canonicalize({ ...existing.payload, event_id: existing.event_id, replay: true }));
353
+ }
354
+ const record = this.#append('review_decided', { attempt_id, task_id: attempt.task_id, review_ref, decision: canonicalize(decision) });
355
+ return Object.freeze(canonicalize({ attempt_id, task_id: attempt.task_id, review_ref, decision, event_id: record.event_id, replay: false }));
356
+ });
357
+ }
358
+
359
+ recoverAttempt({ attempt_id, operator_id, harness_process_id, reconciliation, git_dir, lease_ttl_ms = 300000 } = {}) {
360
+ return this.#exclusive(() => {
361
+ for (const [field, value] of Object.entries({ attempt_id, operator_id, harness_process_id })) assertString(value, 'INVALID_RECOVERY', field);
362
+ assertString(git_dir, 'INVALID_RECOVERY', 'git_dir');
363
+ const attempt = this.#attempts().get(attempt_id);
364
+ if (!attempt || ![EXECUTABLE_ATTEMPT_STATE, 'ready_to_merge'].includes(attempt.state)) fail('ATTEMPT_NOT_RECOVERABLE', 'attempt is not recoverable', { attempt_id });
365
+ const now = timestamp(this.#clock);
366
+ if (!isExpired(attempt.claim, now)) fail('LEASE_STILL_ACTIVE', 'active lease cannot be recovered', { attempt_id });
367
+ validateRecoveryReconciliation(reconciliation, attempt, [...this.#effects().values()].filter((effect) => effect.attempt_id === attempt_id), git_dir);
368
+ const claim = canonicalize({
369
+ ...attempt.claim,
370
+ operator_id,
371
+ harness_process_id,
372
+ lease: { acquired_at: now, heartbeat_at: now, expires_at: plusMs(now, lease_ttl_ms) },
373
+ });
374
+ const resume_state = attempt.state;
375
+ const record = this.#append('attempt_recovered', { attempt_id, claim, reconciliation, resume_state }, attempt.claim_event_id);
376
+ return Object.freeze(canonicalize({ attempt_id, claim, resume_state, event_id: record.event_id }));
377
+ });
378
+ }
379
+
380
+ completeAttempt({ completion_id, attempt_id, claim_id, operator_id, state, git, ci, reviews, acceptance_evidence_refs, completed_at, metrics, handoff } = {}) {
381
+ return this.#exclusive(() => {
382
+ assertString(completion_id, 'INVALID_COMPLETION', 'completion_id');
383
+ assertString(attempt_id, 'INVALID_COMPLETION', 'attempt_id');
384
+ assertString(claim_id, 'INVALID_COMPLETION', 'claim_id');
385
+ assertString(operator_id, 'INVALID_COMPLETION', 'operator_id');
386
+ if (!['ready_to_merge', 'merged'].includes(state)) fail('INVALID_COMPLETION_STATE', 'state must be ready_to_merge or merged');
387
+ assertObject(git, 'INVALID_COMPLETION', 'git');
388
+ assertString(git.git_dir, 'INVALID_COMPLETION', 'git.git_dir');
389
+ assertStringArray(git.commit_refs, 'INVALID_COMPLETION', 'git.commit_refs', { allowEmpty: false });
390
+ assertObject(ci, 'INVALID_COMPLETION', 'ci');
391
+ if (ci.verdict !== 'passed') fail('CI_NOT_PASSED', 'completion requires passed CI evidence');
392
+ assertStringArray(ci.evidence_refs, 'INVALID_COMPLETION', 'ci.evidence_refs', { allowEmpty: false });
393
+ assertStringArray(reviews, 'MISSING_REVIEW_EVIDENCE', 'reviews', { allowEmpty: false });
394
+ assertStringArray(acceptance_evidence_refs, 'MISSING_ACCEPTANCE_EVIDENCE', 'acceptance_evidence_refs', { allowEmpty: false });
395
+ if (metrics !== undefined) {
396
+ assertObject(metrics, 'INVALID_COMPLETION_METRICS', 'metrics');
397
+ for (const field of ['actual_human_effort', 'actual_ai_processing']) {
398
+ assertObject(metrics[field], 'INVALID_COMPLETION_METRICS', `metrics.${field}`);
399
+ assertString(metrics[field].unit, 'INVALID_COMPLETION_METRICS', `metrics.${field}.unit`);
400
+ assertString(metrics[field].evidence_ref, 'INVALID_COMPLETION_METRICS', `metrics.${field}.evidence_ref`);
401
+ if (typeof metrics[field].value !== 'number' || !Number.isFinite(metrics[field].value) || metrics[field].value < 0) fail('INVALID_COMPLETION_METRICS', `metrics.${field}.value must be a non-negative finite number`);
402
+ }
403
+ if (!Number.isInteger(metrics.rework_cycles) || metrics.rework_cycles < 0) fail('INVALID_COMPLETION_METRICS', 'metrics.rework_cycles must be a non-negative integer');
404
+ assertStringArray(metrics.rework_review_refs, 'INVALID_COMPLETION_METRICS', 'metrics.rework_review_refs');
405
+ if (metrics.rework_review_refs.length !== metrics.rework_cycles) fail('INVALID_COMPLETION_METRICS', 'each rework cycle requires one review reference');
406
+ }
407
+ const existing = this.#records.find((record) => record.type === 'attempt_completed' && record.payload.completion.completion_id === completion_id);
408
+ if (existing) fail('COMPLETION_ALREADY_EXISTS', 'completion_id is immutable', { completion_id });
409
+ const attempt = this.#attempts().get(attempt_id);
410
+ const allowedTransition = (attempt?.state === EXECUTABLE_ATTEMPT_STATE && state === 'ready_to_merge') || (attempt?.state === 'ready_to_merge' && state === 'merged');
411
+ if (!allowedTransition) fail('INVALID_COMPLETION_TRANSITION', 'completion transition is not allowed for attempt state', { attempt_id, state, current_state: attempt?.state });
412
+ validateAttemptBinding({ attempt, handoff });
413
+ if (attempt.claim.claim_id !== claim_id || attempt.claim.operator_id !== operator_id) fail('CLAIM_OWNER_MISMATCH', 'only the active claim owner may complete an attempt', { attempt_id });
414
+ this.#assertLiveLease(attempt, timestamp(this.#clock));
415
+ this.#assertAcceptedReviews(attempt_id, reviews);
416
+ if (!git.commit_refs.includes(attempt.git_commit_ref)) fail('GIT_COMMIT_MISMATCH', 'completion must include the sealed attempt commit', { attempt_id });
417
+ for (const commit of git.commit_refs) verifyLocalGitCommit({ git_dir: git.git_dir, git_commit_ref: commit });
418
+ if (ci.commit_ref !== undefined && ci.commit_ref !== attempt.git_commit_ref) fail('CI_SHA_MISMATCH', 'CI evidence must be bound to sealed attempt commit', { attempt_id });
419
+ const now = timestamp(this.#clock);
420
+ const completion = canonicalize({
421
+ completion_id,
422
+ attempt_id,
423
+ task_id: attempt.task_id,
424
+ state,
425
+ git: { ...(git.base_ref ? { base_ref: git.base_ref } : {}), commit_refs: git.commit_refs, ...(git.pr_ref ? { pr_ref: git.pr_ref } : {}) },
426
+ ci: { evidence_refs: ci.evidence_refs, verdict: ci.verdict, ...(ci.commit_ref ? { commit_ref: ci.commit_ref } : {}) },
427
+ reviews,
428
+ acceptance_evidence_refs,
429
+ ...(metrics === undefined ? {} : { metrics }),
430
+ completed_at: completed_at ?? now,
431
+ });
432
+ if (!RFC3339.test(completion.completed_at)) fail('INVALID_COMPLETION', 'completed_at must be RFC3339');
433
+ const record = this.#append('attempt_completed', { completion });
434
+ return Object.freeze(canonicalize({ ...completion, event_id: record.event_id }));
435
+ });
436
+ }
437
+ }
@@ -0,0 +1,24 @@
1
+ # CHATI Release Lane
2
+
3
+ Fixture-only C-11 contract. This package accepts only `FakeReleaseTransport` and has no network client, credential loader, Git command, deployment command or timer.
4
+
5
+ `ReleaseLane` creates immutable hash-chained records. A release needs non-empty source completion references verified by an injected C-06 completion verifier as `merged` and bound to the same immutable reference, an immutable plan reference, passed fake smoke evidence and a human authorization whose `scope_hash` matches the exact release scope. Rollback is a separate attempt with a separate human authorization and scope hash. The package rejects accessors and non-plain nested inputs before it canonicalizes or records them.
6
+
7
+ ## Legacy removal readiness
8
+
9
+ | Legacy surface | Replacement in G3 | Parity and migration evidence | Rollback | Responsibility | Status |
10
+ | --- | --- | --- | --- | --- | --- |
11
+ | BUILD agent | `chati-rail` execution attempt | G3-4 fixtures, G3-9 pending | legacy remains intact | Gabriel, technical policy | not-ready |
12
+ | QA Implementation | `chati-review-council` plus RAIL completion | G3-5 fixtures, G3-9 pending | legacy remains intact | Gabriel, technical policy | ready-for-shadow |
13
+ | QA Visual | browser capability contract | G3-7B fixtures, real visual parity pending | legacy remains intact | Gabriel, browser policy | not-ready |
14
+ | DevOps / release | this fake-only release lane | C-11 tests below, real release deliberately absent | legacy remains intact | Gabriel, release policy | ready-for-shadow |
15
+ | Legacy hooks | provider-neutral policy and adapters | G3-9 migration fixture pending | legacy remains intact | Gabriel, CHATI core | not-ready |
16
+ | Provider maps | provider registry and harness adapters | G3-1 to G3-3 tests, parity pending | legacy remains intact | Gabriel, CHATI core | ready-for-shadow |
17
+
18
+ No row is `ready-for-removal`. This matrix is not authorization to delete or deploy.
19
+
20
+ ## Verification
21
+
22
+ ```sh
23
+ npm -w @chati/release-lane test
24
+ ```