diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1db9786ba..59d48b536 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,9 @@ jobs: steps: - name: Check out repository uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + persist-credentials: false - name: Set up Node.js uses: actions/setup-node@v7 @@ -99,8 +102,13 @@ jobs: run: npm run execution-capsule:test - name: Validate v0.12 reduced-release scope + if: ${{ hashFiles('runtime/orchestration/state-engine-v2.mjs') == '' }} run: npm run release-scope:v012 + - name: Validate v0.13 State Engine V2 increment + if: ${{ hashFiles('runtime/orchestration/state-engine-v2.mjs') != '' }} + run: npm run v013-state-engine-v2:test + - name: Validate v0.12 non-T04 feature increments run: | npm run lifecycle-instances:test @@ -120,7 +128,53 @@ jobs: run: npm pack --dry-run - name: Verify installed v0.12 consumer package + if: ${{ hashFiles('runtime/orchestration/state-engine-v2.mjs') == '' }} run: npm run consumer-smoke:v012 - name: Validate exact release command + if: ${{ hashFiles('runtime/orchestration/state-engine-v2.mjs') == '' }} run: npm run release:validate + + - name: Validate v0.13 full compatibility regression + if: ${{ hashFiles('runtime/orchestration/state-engine-v2.mjs') != '' }} + run: npm run v013:regression + + - name: Verify v0.13 risk-3 acceptance job contract (negative control) + run: node --test scripts/v013-t04-acceptance-ci-contract.test.mjs + + t04_acceptance: + name: T04 independently reviewed risk-3 acceptance + if: ${{ github.event_name == 'pull_request' && github.head_ref == 'feature/v0.13-state-engine-v2' }} + needs: validate + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + pull-requests: read + issues: read + actions: read + steps: + - name: Check out exact reviewed integration source + uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.head.sha }} + persist-credentials: false + - name: Set up Node.js + uses: actions/setup-node@v7 + with: + node-version: 22 + - name: Establish exact-source v0.13 Development Contract + run: node scripts/v013-state-engine-v2-contract.mjs + - name: Verify immutable external T04 acceptance evidence + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: node scripts/v013-t04-acceptance-gate.mjs + - name: Preserve canonical T04 verdict evidence + if: ${{ success() }} + uses: actions/upload-artifact@v4 + with: + name: dkf-v013-t04-acceptance-evidence-${{ github.event.pull_request.head.sha }} + path: .development-kit/acceptance/DKF130-T04.json + if-no-files-found: error + include-hidden-files: true + retention-days: 90 diff --git a/docs/03-reference/scripts/state-engine-v2-benchmark.md b/docs/03-reference/scripts/state-engine-v2-benchmark.md new file mode 100644 index 000000000..12d1befa5 --- /dev/null +++ b/docs/03-reference/scripts/state-engine-v2-benchmark.md @@ -0,0 +1,30 @@ +# state-engine-v2-benchmark.mjs + +## Purpose + +A reproducible, disposable **v0.13 State Engine V2 endurance fixture** that checks canonical event append and history reconstruction without modifying an existing project. + +The script creates a temporary project, appends generic entity revisions, verifies the monotonic canonical hash chain and final entity state, then removes the fixture. + +## Usage + +```bash +npm run state-engine-v2:benchmark +node scripts/state-engine-v2-benchmark.mjs --events 1000 +``` + +The bounded CI/review fixture uses 300 events. The CLI accepts 1–5,000 events for targeted measurements. + +## Reported observations + +The script emits `T04_ENDURANCE_EVIDENCE` JSON including operation count, platform/Node version, append duration, integrity-read duration, final canonical-ledger bytes and modeled write savings against the former full-ledger-rewrite design. + +The historical rewrite comparison is a deterministic calculation from observed ledger lengths, not a measured execution of the retired implementation and not a release-wide performance claim. + +## Authority and safety + +- `events.jsonl` remains canonical. +- Snapshot/index are rebuildable. +- Interrupted appends are recovery-gated. +- The fixture uses a disposable temporary project. +- Passing the benchmark does not satisfy independent risk-3 acceptance. diff --git a/docs/03-reference/scripts/t04-review-thread-blockers.md b/docs/03-reference/scripts/t04-review-thread-blockers.md new file mode 100644 index 000000000..e4fc672e2 --- /dev/null +++ b/docs/03-reference/scripts/t04-review-thread-blockers.md @@ -0,0 +1,28 @@ +# t04-review-thread-blockers.mjs + +## Purpose + +Provides deterministic helpers for the **v0.13 T04 risk-3 review gate**. + +T04 acceptance must refuse approval while an authenticated review thread remains unresolved, while source anchoring is ambiguous, or while authenticated review evidence is malformed. + +## Usage + +The module is imported by T04 acceptance/review tooling and is not itself an acceptance authority. + +```js +import { unresolvedReviewThreads } from './t04-review-thread-blockers.mjs'; +const blockers = unresolvedReviewThreads(githubReviewThreads); +``` + +The helper also validates canonical review-request source SHA anchoring and authenticated review-thread page structure. + +## Verification + +```bash +node --test scripts/t04-review-thread-blockers.test.mjs +``` + +The regressions cover unresolved findings, conflicting or duplicated source SHAs, malformed review evidence and pagination failures. + +This helper cannot generate reviewer PASS results, resolve comments, or bypass source-bound independent review. diff --git a/docs/03-reference/scripts/v013-state-engine-v2-contract.md b/docs/03-reference/scripts/v013-state-engine-v2-contract.md new file mode 100644 index 000000000..290f93e2f --- /dev/null +++ b/docs/03-reference/scripts/v013-state-engine-v2-contract.md @@ -0,0 +1,83 @@ +# v013-state-engine-v2-contract.mjs + +## Purpose + +`scripts/v013-state-engine-v2-contract.mjs` creates or reuses the Development Contract for **DKF130-T04 — State Engine V2**, the first v0.13 priority carried forward from deferred DKF120-T04. + +The runtime contract is persisted under: + +```text +.development-kit/contracts/INC-DKF130-T04/ +``` + +The directory remains git-ignored. The committed generator makes contract construction reproducible from the exact v0.13 authority files. + +## Bound authority + +The contract fingerprints: + +- `dkf-v0.13-state-engine-v2-specification.md`; +- `dkf-v0.13-t04-release-authority.md`; +- the released v0.12.1/v0.12.0 changelog baseline; +- the historical v0.12 State Engine technical design and PLAN as supporting context. + +## Restored acceptance criteria + +- `DKF-120-AC-012` +- `DKF-120-AC-013` +- `DKF-120-AC-014` +- `DKF-120-AC-015` +- `DKF-120-AC-034` + +These retain their original IDs because v0.12 explicitly deferred rather than replaced them. + +## High-assurance gates + +The contract is risk level 3 and binds the high-assurance Gate Profile. + +Expected effective gates: + +```text +verification: + architecture-verification + security-audit + specification + tests + +reviewers: + architecture-reviewer + code-reviewer + security-reviewer + +control domains: + security +``` + +T04 cannot be accepted from tests alone. + +## Target binding + +```text +primary: dkf-framework +affected: dkf-framework +verification: dkf-framework +``` + +## Usage + +```bash +node scripts/v013-state-engine-v2-contract.mjs +``` + +The generator validates source freshness, target freshness and effective gates immediately. + +## Boundary + +The contract does not authorize: + +- application/domain-specific state policy; +- distributed locking; +- Issue #72 hostile same-account writer hardening; +- removal of v0.12.1 features; +- release publication; +- deletion of retained legacy recovery assets. diff --git a/docs/03-reference/scripts/v013-t04-acceptance-gate.md b/docs/03-reference/scripts/v013-t04-acceptance-gate.md new file mode 100644 index 000000000..51144a544 --- /dev/null +++ b/docs/03-reference/scripts/v013-t04-acceptance-gate.md @@ -0,0 +1,29 @@ +# v013-t04-acceptance-gate.mjs + +## Purpose + +`scripts/v013-t04-acceptance-gate.mjs` is the fail-closed, risk-3 DKF130-T04 acceptance evaluator for the **v0.13.0 integration candidate PR #92**. It is not a general release approval and must never run as a shortcut around independent reviews or Windows restore durability evidence. + +## Execution context + +The script requires an authenticated GitHub Actions pull-request event for `eybersjp/development-kit`, PR #92, from branch `feature/v0.13-state-engine-v2`. The dedicated `t04_acceptance` job must depend on both Ubuntu and Windows validation jobs. The job cannot authenticate evidence from a local invocation or an unrelated diagnostic PR. + +## Required independent evidence + +The committed `docs/04-architecture/dkf-v013-t04-independent-review-receipts.json` register must bind one immutable reviewed source commit and three distinct, authenticated GitHub `APPROVED` reviews for code, architecture, and security, with exact SHA and role scope. All review threads must be resolved. Review metadata is fetched from GitHub, not trusted from a self-authored receipt. + +Issue #73 must be resolved without waiver. Controlled NTFS physical power-loss evidence must be source-bound, independently reviewed and supported by a verified GitHub Actions artifact and digest. Two distinct full Ubuntu/Windows workflow executions must have passed on the identical reviewed commit. The source tree, Git index and executable modes must match the reviewed commit except for the receipt-only follow-up. + +## Deterministic decision + +After authenticated evidence verification, the script constructs the v0.13 Development Contract verification record, independent review records, security control coverage, and architecture drift assessment. The canonical acceptance engine must return `ACCEPTED` before any acceptance record is written. + +The acceptance record is written under `.development-kit/acceptance/DKF130-T04.json` in the runner workspace. **A runner-local file is not, by itself, a durable repository-persisted acceptance verdict.** Release integration requires independently verified retention and retrieval of the authentic record through an approved governance artifact or repository persistence mechanism; this diagnostic implementation does not authorize a merge or release merely because its job passes. + +## Usage and limits + +```bash +node scripts/v013-t04-acceptance-gate.mjs +``` + +The command is intended to run only inside its restricted GitHub Actions job. It does not publish releases, close blocking issues, resolve review threads, modify production state or waive full Windows legacy-backup restoration. Source-bound reviews and physical sudden-power-loss evidence cannot be simulated by ordinary CI. diff --git a/docs/03-reference/scripts/v013-t04-review-provenance.md b/docs/03-reference/scripts/v013-t04-review-provenance.md new file mode 100644 index 000000000..dc8199455 --- /dev/null +++ b/docs/03-reference/scripts/v013-t04-review-provenance.md @@ -0,0 +1,15 @@ +# v013-t04-review-provenance.mjs + +## Purpose + +`scripts/v013-t04-review-provenance.mjs` supplies the narrowly scoped authenticated-review effective-state guard used by the DKF130-T04 v0.13 risk-3 acceptance evaluator. + +## Review provenance contract + +A valid role receipt identifies a distinct authenticated GitHub `APPROVED` review bound to the immutable reviewed source commit. The single-review endpoint alone is insufficient: an earlier approved review may have been superseded by a later `CHANGES_REQUESTED`, `DISMISSED`, or approval on a different commit. + +`assertCurrentRoleApproval(approval, submittedReviews, reviewedCommit)` examines the authenticated, paginated pull-request review timeline using the immutable positive numeric GitHub `user.id` (not mutable/case-insensitive `user.login`). It ignores non-decisive `COMMENTED` submissions, requires valid submitted-at ordering witnesses, and fails closed unless the latest decisive review by the same account, including after username changes, is the exact approved receipt for the exact reviewed commit. The production gate also binds distinct role reviewers, source-author exclusion and the independent physical NTFS reviewer to authenticated numeric account IDs. The caller must retrieve the review timeline through authenticated GitHub API calls and retain the independent role, distinct identity, zero unresolved threads, and physical NTFS evidence gates. + +## Validation + +`node --test scripts/v013-t04-acceptance-ci-contract.test.mjs` includes T04-A19/A23/A24 adversarial cases for revoked approvals, stale reapprovals, dismissed reviews, and harmless later comments. Both Ubuntu and Windows full CI jobs must pass on the same candidate commit. This helper never issues approvals, marks review threads resolved, merges PRs, or emits `ACCEPTED` by itself. diff --git a/docs/04-architecture/dkf-v0.13-state-engine-v2-specification.md b/docs/04-architecture/dkf-v0.13-state-engine-v2-specification.md new file mode 100644 index 000000000..796ec8afd --- /dev/null +++ b/docs/04-architecture/dkf-v0.13-state-engine-v2-specification.md @@ -0,0 +1,330 @@ +# Development Kit v0.13.0 — State Engine V2 Specification + +**Status:** Implementation authority for v0.13 T04 +**Date:** 14 October 2026 +**Base release:** Development Kit v0.12.1 +**Priority:** P0 / first v0.13 increment +**Deferred source:** DKF120-T04 from the v0.12 Scale, Context & Iteration PLAN +**Binding release decision:** Issue #82 +**Blocking Windows recovery issue:** Issue #73 + +## 1. Objective + +Complete and independently accept the State Engine V2 work deliberately deferred from v0.12.0, while preserving all released v0.12.1 public commands, lifecycle behavior, gate semantics, verification extensions, discovery/re-entry behavior, Control Center projections and stable-state compatibility. + +State Engine V2 SHALL become the canonical persistent development-state authority after a verified migration/cutover. + +It SHALL NOT become application, business or domain state. + +## 2. General-purpose architecture rule + +DKF core stores generic control-plane state only. + +The implementation MUST NOT introduce: + +- application-specific entities; +- industry-specific lifecycle policy; +- provider-specific state semantics; +- project business-status interpretation; +- target-name-based behavior. + +Project/domain state remains outside canonical DKF development-state authority unless a generic extension explicitly persists opaque project-owned data under an approved contract. + +## 3. Required canonical layout + +State Engine V2 uses: + +```text +.development-kit/state/ + events.jsonl + snapshot.json + index.db + schema.json + pending-commit.json # recovery aid, not the sole durability witness + state.lock + migration-complete.json + legacy-backup.json +``` + +Authority order: + +1. `events.jsonl` — canonical append-only development-state history. +2. `snapshot.json` — materialized current view, rebuildable. +3. `index.db` — disposable/rebuildable acceleration index. +4. Legacy state — recovery authority only until verified migration/cutover. +5. `pending-commit.json` — recovery intent/witness; absence alone MUST NOT make a torn canonical ledger unrecoverable. + +SQLite MUST NOT be the sole source of truth. + +## 4. Acceptance criteria restored for v0.13 + +The v0.12 release explicitly deferred these criteria; v0.13 reactivates them unchanged in intent. + +### DKF-120-AC-012 +State history is append-only and canonical, with deterministic event ordering and integrity chaining. + +### DKF-120-AC-013 +Current state is materialized from canonical history and can be rebuilt deterministically. + +### DKF-120-AC-014 +The acceleration index can be deleted/corrupted and rebuilt without changing semantic state. + +### DKF-120-AC-015 +Supported legacy state migrates idempotently, fail-closed, recoverably and semantically equivalently before cutover. + +### DKF-120-AC-034 +Canonical transition-state file growth is bounded and no longer proportional to normal state-transition count. + +## 5. v0.13 compatibility requirements + +### INV-130-001 — v0.12.1 behavior preservation +All released v0.12.1 commands and included T01–T03/T05–T10 behavior remain supported. + +### INV-130-002 — no silent cutover +A project with legacy state MUST NOT switch to V2 until migration has succeeded and a migration-complete witness exists. + +### INV-130-003 — legacy recovery retained +Legacy state and backup material MUST remain recoverable until verified cutover and explicit later cleanup policy. + +### INV-130-004 — fresh-project V2 +A fresh project with no legacy persistent workflow state uses State Engine V2 directly. + +### INV-130-005 — acceptance semantics unchanged +Changing persistence authority MUST NOT weaken Development Contracts, source staleness, gate profiles, verification isolation, reviewer requirements, human approval or deterministic acceptance. + +### INV-130-006 — current feature integration +The v0.12.1 Lifecycle Instance, discovery/re-entry, gate-profile, verification-extension, complexity and Control Center code must continue to operate against their released interfaces. Persistence adapters may change underneath them; feature semantics may not silently change. + +## 6. Runtime commit durability contract + +### 6.1 Canonical event append + +Normal mutation is: + +```text +lock + -> recover any prior interrupted commit + -> validate canonical prefix + -> create pending recovery intent + -> append event suffix physically + -> flush canonical ledger + -> materialize snapshot/index + -> verify derived state against canonical history + -> acknowledge/retire pending intent +unlock +``` + +A normal append MUST NOT rewrite the entire historical ledger. + +### 6.2 Pending journal is not the only witness + +Issue #73 identified that a Windows namespace update for `pending-commit.json` cannot be treated as universally power-loss durable merely because a directory flush syscall returns successfully. + +Therefore v0.13 SHALL additionally make the canonical ledger self-recovering for an unambiguous torn **trailing append** even when the pending journal is absent. + +Allowed automatic repair: + +- incomplete/invalid final byte suffix after the last complete newline; +- a complete final JSON event whose canonical bytes are present but final newline is missing, provided event schema/hash-chain validation succeeds; +- only the final append boundary. + +Automatic repair MUST NOT: + +- discard a complete but hash-invalid event; +- repair corruption in the middle of history; +- accept sequence gaps; +- accept previous-hash mismatch; +- rewrite an arbitrary historical prefix. + +Any ambiguous corruption fails closed. + +### 6.3 Windows persistence statement + +DKF distinguishes: + +1. documented OS/API semantics; +2. deterministic fault-injection behavior; +3. actual power-loss/hardware persistence evidence. + +A successful `FlushFileBuffers`, `fsyncSync`, or `MoveFileExW(MOVEFILE_WRITE_THROUGH)` call MUST NOT be described as universal hardware power-loss proof. + +Microsoft documents that: + +- directory handles require `CreateFile(... FILE_FLAG_BACKUP_SEMANTICS ...)`; +- `FILE_FLAG_WRITE_THROUGH` requests persistent-media write-through and causes NTFS metadata changes such as rename processing to be flushed, subject to hardware support; +- `FlushFileBuffers` flushes buffered information for the supplied handle/device; +- `MOVEFILE_WRITE_THROUGH` explicitly guarantees flushing for copy/delete moves, and MUST NOT be over-generalized as proof of every same-volume directory rename transaction. + +v0.13 tests and documentation must state these limits. + +## 7. Windows legacy-backup restore/recovery contract + +Full Windows restore/recovery support remains required by the Product Owner decision recorded under Issue #73. + +A blanket Windows disable is prohibited. + +### 7.1 Supported transaction model + +Restore uses a stable sibling recovery anchor outside the directory being replaced. + +The transaction is monotonic: + +```text +PREPARED +ORIGINAL_DISPLACED +CANDIDATE_PUBLISHED +PROMOTION_ACKNOWLEDGED +CLEANUP_SAFE +``` + +Each phase has authenticated path/inode/content witnesses. + +The durable recovery anchor MUST NOT be deleted as the indication that a restore succeeded. + +Instead: + +- append/replace the transaction state with an explicit terminal phase; +- retain enough witness material for restart reconciliation; +- cleanup of old transaction records **and retained displaced-original directories** is a separate bounded, explicitly reviewed offline maintenance operation, not part of the critical promotion acknowledgement. The exact original directory inode must remain available after normal success and promoted recovery until that decision; retained data requires disk-capacity and access-control management. + +### 7.2 Crash ambiguity rule + +After interruption, recovery inspects: + +- recovery transaction state; +- target root identity; +- staged candidate identity; +- displaced original identity; +- fingerprints; +- filesystem/volume support information. + +It then either: + +- deterministically resumes; +- deterministically rolls back; +- reports completed; +- or fails closed with all recoverable assets preserved. + +Absence of one directory entry is never sufficient evidence that no transaction existed. + +### 7.3 Windows supported-storage contract + +For v0.13 deterministic automatic restore, DKF SHALL positively identify a locally supported Windows persistence environment before destructive root mutation. + +Initial supported Windows automatic durability profile: + +- local Windows filesystem; +- NTFS; +- non-network path; +- native write-through/flush helper available and successfully probed. + +Other Windows storage profiles MUST fail closed **for the attempted destructive transition**, preserve recovery assets and provide operator recovery evidence. This is not a blanket disable of Windows restore APIs. + +Support may be broadened only with source-backed OS/filesystem evidence and tests. + +### 7.4 Required Issue #73 fixtures + +The final suite MUST include Windows-specific, non-skipped coverage equivalent to: + +- W73-01 recovery-intent publication boundary; +- W73-02 original displacement boundary; +- W73-03 candidate publication boundary; +- W73-04 promotion acknowledgement boundary; +- W73-05 retained displaced-original witness and separately approved offline cleanup boundary; +- W73-06 interrupted rollback and repeat recovery; +- W73-07 invalid/tampered witness; +- W73-08 unsupported persistence profile fails safely before destructive mutation; +- W73-09 pre-existing interrupted restore remains recoverable; +- W73-10 normal full Windows restore succeeds and preserves specified metadata/integrity. + +Fault injection is evidence of ordering/error safety, not physical power-cut certification. + +## 8. Migration contract + +Migration MUST: + +- inventory supported legacy state; +- fingerprint source state before mutation; +- create a complete backup bundle; +- import canonical V2 entities/events; +- verify semantic equivalence against legacy source; +- create migration-complete witness only after verification; +- be idempotent; +- refuse changed/corrupt source; +- preserve legacy source until verified cutover; +- support explicit offline backup restoration. + +v0.12.1 feature state introduced after the original T04 design MUST be inventoried and classified before cutover. Evidence artifacts and disposable caches are not automatically promoted to canonical state. + +## 9. Locking and concurrency + +Within one local workspace: + +- one writer lock protects event sequencing and commit publication; +- stale locks may be reclaimed only when ownership is provably dead; +- a live local PID lock is never stolen due to age alone; +- multiple concurrent writers serialize deterministically. + +Distributed cross-host locking is not a v0.13 guarantee unless separately specified. + +Same-account hostile concurrent mutation remains the separate threat-model issue recorded as Issue #72 and is not silently folded into this acceptance. + +## 10. Security invariants + +- no symlink/junction/path traversal may redirect state or restore writes outside declared boundaries; +- internal canonical files must be ordinary files with expected link/inode properties; +- restore destination aliases/case collisions fail closed; +- unexpected filesystem objects fail closed; +- recovery never destroys the last authenticated recoverable copy; +- no PowerShell string interpolation of untrusted path data; +- native helper failure propagates as a typed failure; +- no silent fallback from a required Windows durability primitive to plain `fs.renameSync`. + +## 11. Required independent gates + +T04 is risk level 3. + +Required final gates: + +- specification verification; +- complete automated tests; +- Ubuntu exact-head CI; +- Windows exact-head CI; +- independent code review; +- independent architecture review; +- independent security review; +- security control-domain PASS; +- all in-scope P1/P2 review threads resolved; +- deterministic acceptance receipt bound to the exact reviewed source SHA. + +Test success alone is not acceptance. + +## 12. Implementation strategy from v0.12.1 + +The old v0.12 T04 branches/PRs are evidence sources only. + +v0.13 implementation MUST start from current `main` and selectively port/reconcile: + +- State Engine V2 canonical ledger/snapshot/index; +- legacy migration/backup/restore; +- Autopilot state-store adapter; +- orchestration-run state adapter; +- schemas and tests; +- Windows durability helper/protocol; +- review/acceptance tooling. + +It MUST preserve later v0.12.1 changes such as Gate Profiles and Verification Extension acceptance behavior. + +No force merge or wholesale replacement of current main files with old T04 versions is permitted. + +## 13. Release boundary + +Completion of this T04 increment authorizes only progression toward v0.13.0 release integration. + +It does not itself authorize: + +- publishing v0.13.0; +- deleting legacy state; +- dropping Windows support; +- closing Issue #73 before exact-source independent acceptance; +- adding unrelated v0.13 features. diff --git a/docs/04-architecture/dkf-v0.13-t04-release-authority.md b/docs/04-architecture/dkf-v0.13-t04-release-authority.md new file mode 100644 index 000000000..8321f5ee1 --- /dev/null +++ b/docs/04-architecture/dkf-v0.13-t04-release-authority.md @@ -0,0 +1,100 @@ +# Development Kit v0.13.0 — T04 Release Authority + +**Status:** Binding implementation context +**Date:** 14 October 2026 +**Current released baseline:** v0.12.1 +**Deferred increment:** DKF120-T04 — State Engine V2 +**v0.13 implementation identity:** DKF130-T04 + +## 1. Release-line decision + +Development Kit v0.12.0 deliberately shipped without State Engine V2. + +The v0.12 release retained the stable v0.11-style persistence authority and shipped T01–T03 plus T05–T10. The following State Engine V2 criteria were explicitly deferred rather than counted as passed: + +- `DKF-120-AC-012` +- `DKF-120-AC-013` +- `DKF-120-AC-014` +- `DKF-120-AC-015` +- `DKF-120-AC-034` + +The repository changelog records T04 State Engine V2 as the **highest-priority v0.13.0** work. + +## 2. Product Owner authority + +GitHub Issue #82 is the binding Product Owner release-scope decision for v0.12. + +Its effect on v0.13 is: + +- T04 was deferred, not cancelled; +- the original T04 reliability intent remains; +- full Windows legacy-backup restore/recovery remains required; +- risk-3 independent code, architecture and security review remains required; +- T04 may not be represented as accepted merely because implementation tests pass. + +## 3. Windows blocker + +GitHub Issue #73 remains the blocking Windows durability/recovery issue. + +The accepted direction requires: + +- no blanket Windows restore disable; +- a documented supported Windows filesystem/API contract; +- preservation of recovery evidence when durability cannot be proven; +- deterministic crash-boundary/fault-injection coverage; +- Windows and Ubuntu exact-source validation; +- fresh code, architecture and security review before acceptance. + +The current v0.13 design treats successful durability-related syscalls as API evidence, not as universal hardware power-loss certification. + +## 4. Compatibility baseline + +v0.12.1 is the integration baseline. + +T04 implementation must preserve released behavior for: + +- Cost Observatory; +- Workspace Targets; +- Execution Capsules and Context Cache; +- Lifecycle Instances; +- Discovery and Controlled Re-entry; +- Gate Profiles; +- Verification Extension SDK; +- Complexity Delta Guard; +- Control Center v0.12 projections; +- Design Authority; +- Secrets & Configuration Readiness; +- Development Modes; +- IDEA authority; +- deterministic acceptance and independent verification. + +State Engine V2 replaces persistence authority under these released interfaces. It does not roll their behavior back to historical T04 branch versions. + +## 5. Historical T04 branches + +Historical T04 branches and PRs are evidence/source material only: + +- original T04 implementation/validation; +- T04 reliability-hardening work; +- Windows durability investigations; +- audit/review findings. + +They are not accepted release authority and must not be merged wholesale over v0.12.1. + +v0.13 implementation starts from current `main`, selectively ports proven mechanisms, resolves the outstanding blockers and re-runs independent acceptance. + +## 6. Acceptance boundary + +T04 is not complete until the exact reviewed source has: + +- applicable AC verification PASS; +- Ubuntu validation PASS; +- Windows validation PASS; +- code review PASS; +- architecture review PASS; +- security review PASS; +- required security control-domain PASS; +- no unresolved in-scope major/critical review findings; +- deterministic acceptance computed from source-bound evidence. + +Only then may the project proceed to v0.13 release integration. diff --git a/docs/04-architecture/dkf-v013-t04-independent-review-receipts.json b/docs/04-architecture/dkf-v013-t04-independent-review-receipts.json new file mode 100644 index 000000000..ec293e400 --- /dev/null +++ b/docs/04-architecture/dkf-v013-t04-independent-review-receipts.json @@ -0,0 +1,9 @@ +{ + "schemaVersion": "1.0.0", + "reviewedCommit": null, + "reviews": [], + "validationRunIds": [], + "windowsPhysicalPowerLoss": null, + "status": "PENDING_INDEPENDENT_REVIEWS", + "policy": "Risk-3 requires three distinct authenticated exact-source GitHub approvals, two full cross-platform runs, physical NTFS power-loss evidence and a canonical persisted Development Contract ACCEPTED verdict." +} diff --git a/docs/SUMMARY.md b/docs/SUMMARY.md index fed5dfcee..dc62ebc84 100644 --- a/docs/SUMMARY.md +++ b/docs/SUMMARY.md @@ -195,6 +195,11 @@ * [phase03-cost-observatory-contract](03-reference/scripts/phase03-cost-observatory-contract.md) * [phase04-workspace-target-engine-contract](03-reference/scripts/phase04-workspace-target-engine-contract.md) * [phase05-execution-capsule-contract](03-reference/scripts/phase05-execution-capsule-contract.md) +* [v013-state-engine-v2-contract](03-reference/scripts/v013-state-engine-v2-contract.md) +* [v013-t04-acceptance-gate](03-reference/scripts/v013-t04-acceptance-gate.md) +* [v013-t04-review-provenance](03-reference/scripts/v013-t04-review-provenance.md) +* [state-engine-v2-benchmark](03-reference/scripts/state-engine-v2-benchmark.md) +* [t04-review-thread-blockers](03-reference/scripts/t04-review-thread-blockers.md) * [sync-plugin](03-reference/scripts/sync-plugin.md) * [ui-preview](03-reference/scripts/ui-preview.md) * [token-audit](03-reference/scripts/token-audit.md) @@ -242,6 +247,8 @@ * [DKF Scale, Context & Iteration — Phase 0/1 Baseline](04-architecture/dkf-scale-context-iteration-phase01-baseline.md) * [DKF Scale, Context & Iteration — v0.12 Specification](04-architecture/dkf-scale-context-iteration-v0.12-specification.md) * [DKF Scale, Context & Iteration — v0.12 Technical Design](04-architecture/dkf-scale-context-iteration-v0.12-technical-design.md) +* [DKF v0.13 State Engine V2 Specification](04-architecture/dkf-v0.13-state-engine-v2-specification.md) +* [DKF v0.13 T04 Release Authority](04-architecture/dkf-v0.13-t04-release-authority.md) * [DKF Scale, Context & Iteration — v0.12 Implementation PLAN](04-architecture/dkf-scale-context-iteration-v0.12-implementation-plan.md) * [DKF v0.12 — PO-approved Release Scope (T04 deferred)](04-architecture/dkf-v0.12-release-scope-amendment.md) * [DKF v0.12 — Matched Comparative Cost and Context Evidence](04-architecture/dkf-v0.12-release-comparative-evidence.md) diff --git a/memory.md b/memory.md index 7147c09e7..f5e715646 100644 --- a/memory.md +++ b/memory.md @@ -2,13 +2,110 @@ ## Published Baseline -- **Current published release candidate:** v0.11.2 (1 October 2026). -- **Package version:** 0.11.2. +- **Current published release baseline:** v0.12.1 (October 2026). +- **Package version:** 0.12.1. - **Core lifecycle:** `UNDERSTAND → DEFINE → DESIGN → PLAN → IMPLEMENT → VERIFY → REVIEW → SIMPLIFY → COMPLETE`. - **Public command surface:** 16 workflow commands. - **Engineering skills:** 48. - **Primary position:** DKF is the reliability control plane for agentic software development — **AI can write it. DKF proves it.** +## v0.13.0 P0 — State Engine V2 T04 (30 October 2026) + +- **T04-H65 valid-prefix canonical truncation versus acknowledged derived witnesses (31 October 2026; NOT ACCEPTED):** A valid, newline-terminated but truncated `events.jsonl` prefix could be accepted by `appendMetadataEvent()` and a replacement event could overwrite an acknowledged sequence, silently rewriting `snapshot.json` and `index.db`. When the snapshot was absent, `repairDerivedLocked()` could also overwrite an index that witnessed later history. Isolated, unmerged [diagnostic PR #146](https://github.com/eybersjp/development-kit/pull/146) added fail-first H65 controls; test-only SHA `0d5e7b637eed9258bfa5d96aff5d8a4a88757b8f` reproduced three missing fail-closed exceptions on **both Ubuntu and Windows** in [RED CI #41225028114](https://github.com/eybersjp/development-kit/actions/runs/41225028114). Narrow correction compares validated snapshot/index sequence and hash to the **original pre-append canonical prefix** before pending-journal publication, derived repair, torn-tail truncation or pending-recovery mutation; EIO/EACCES reading witnesses fails closed, while genuinely older consistent derived views remain rebuildable. Diagnostic source/test `16ac782045401bbb9255ebeacfb344fde8034a5d` passed **both full Ubuntu and Windows** jobs in [CI #41225229774](https://github.com/eybersjp/development-kit/actions/runs/41225229774). Final diagnostic `7522c4455b748b9c59936a3a8b4bcd8567711735` adds a sixth pending-recovery/index-only adversarial control: Ubuntu full validation and Windows State Engine increment passed in [CI #41225295603](https://github.com/eybersjp/development-kit/actions/runs/41225295603); Windows full compatibility was still running at this evidence checkpoint. Ported only this tested runtime and six regressions, plus this memory record, to authoritative draft PR #92; **fresh exact-port CI and two complete same-final-SHA runs are required**. Issue #73 controlled physical NTFS sudden-power-loss proof, three distinct authenticated exact-source code/architecture/security approvals, persisted risk-3 `ACCEPTED`, and protected v0.13 release approval remain outstanding. No waiver, merge or release. + + +- **T04-H64 missing canonical ledger with acknowledged derived-history witnesses (31 October 2026; NOT ACCEPTED):** A committed State Engine V2 event could be silently lost if the `events.jsonl` directory entry disappeared without a pending journal while an existing `snapshot.json` and `index.db` still witnessed committed history. `loadCanonicalEvents()` returned an empty array, `loadStateSnapshot()` returned null and `ensureStateEngineLayout()` could recreate an empty canonical ledger. Test-only authoritative draft PR #92 SHA `6be7e3e2a13777536904c2999c87939bf36b5b1a` added adversarial H64 checks for readers, initialization and writes preserving original witnesses. Both Ubuntu and Windows reproduced RED (`Missing expected exception: canonical reader must fail closed`) in [CI #41218300511](https://github.com/eybersjp/development-kit/actions/runs/41218300511). Narrow runtime fix SHA `ed14bbf17f8b9a8ce8d686f05d6a1cc4d17d3e76` refuses to initialize a missing canonical ledger when a pending journal or published snapshot/index remains; only a genuinely witness-free bootstrap can initialize. Ubuntu complete v0.13 validation PASS, Windows H64/state-engine increment PASS in [CI #41218389134](https://github.com/eybersjp/development-kit/actions/runs/41218389134); Windows full compatibility regression was still in progress at this evidence checkpoint. This memory commit changes the final source SHA and requires fresh exact-head CI and independent review. Physical NTFS sudden-power-loss evidence (Issue #73), three distinct authenticated code/architecture/security approvals, two full Ubuntu/Windows runs on the final reviewed SHA, canonical persisted risk-3 `ACCEPTED`, and protected v0.13 release authorization remain mandatory. No waiver, merge or release. + +- **T04-A25 independent-acceptance/CI witness deadlock (31 October 2026; NOT ACCEPTED):** In `scripts/v013-t04-acceptance-gate.mjs`, security-control evidence correctly had to cite one of the authenticated exact-SHA, full Ubuntu+Windows validation runs, but also demanded `witnessRun.conclusion === 'success'`. The overall run is expected to **fail** before independent approvals because the separate T04 acceptance job fails closed, even when both full platform validation jobs and mandatory steps succeed; this requirement made first acceptance impossible. Test-first regression on actual draft PR #92 at test-only SHA `75ea6a8e7a5669bd8ff667fdc297397d37ed87ed` reproduced `T04-A25` RED on **both Ubuntu and Windows** in [CI #41214400882](https://github.com/eybersjp/development-kit/actions/runs/41214400882). Narrow fix SHA `6b7df4e224dbe8b5bd65dafe4a5dfa4a1a1a99be` removes only the redundant overall-run conclusion assertion, retaining exact reviewed SHA, authenticated allowed run membership, completed-run state, two distinct workflow IDs, full platform job and mandatory-step success, and all independent review/physical NTFS controls. **Both complete Ubuntu and Windows validation jobs PASS** in [CI #41214508947](https://github.com/eybersjp/development-kit/actions/runs/41214508947); independent T04 acceptance correctly FAILS at missing exact-source reviews. This documentation commit changes the integration HEAD, so final exact-head CI must be repeated. Still outstanding: Issue #73 physical NTFS sudden-power-loss evidence, three distinct authenticated code/architecture/security approvals, complete final-source validations, authentic persisted `ACCEPTED` and protected v0.13 release authorization. No gate waiver, fabricated review, merge or release. + + +- **T04-H63 unreadable acknowledged snapshot during derived repair (31 October 2026; NOT ACCEPTED):** `repairDerivedLocked()` previously caught EACCES/EIO from `snapshot.json` reads as though the existing acknowledged-history witness were malformed/disposable, then rewrote the snapshot and index from a potentially truncated canonical prefix. Isolated unmerged diagnostic [PR #144](https://github.com/eybersjp/development-kit/pull/144), test-only SHA `bb2af86868dc9fc95d9fbab5364d36b6b507806c`, reproduced both EACCES and EIO evidence-loss paths on Ubuntu and Windows in [RED CI #41212217175](https://github.com/eybersjp/development-kit/actions/runs/41212217175). Narrow fix separates snapshot filesystem read errors (fail closed except ENOENT) from malformed disposable JSON; source SHA `193380a703a218139240efc649395fac25c81ab6` passed full Ubuntu validation and Windows `T04-H63` regressions in [CI #41212488495](https://github.com/eybersjp/development-kit/actions/runs/41212488495). The first Windows full compatibility attempt failed an unrelated H03 four-worker lock-acquisition timeout; rerun of the failed Windows job **PASSED the full compatibility suite**, including H03 and both H63 regressions. The intermittent H03 timeout warrants investigation; a rerun is not a second independent full workflow execution. Ported only verified H63 test/source to authoritative draft PR #92 commits `586fa89b4c703e9887f18be6c39fd51afc3b967b` and `b02766826cdc6c44dca98f9f2a8dffc33299115d`. This memory commit changes the integration SHA, requiring fresh exact-head CI and two independent complete Ubuntu/Windows runs. Issue #73 controlled physical NTFS sudden-power-loss evidence, three distinct authenticated code/architecture/security approvals, canonical persisted `ACCEPTED` and protected v0.13 release approval remain outstanding. No diagnostic merge, gate waiver or fabricated physical evidence. + + + +- **T04-H62 lock-release EIO false-success and primary-error masking (31 October 2026; NOT ACCEPTED):** A durable canonical append could return an unqualified success despite `releaseStateLock()` silently swallowing EIO while removing its owned lock. Fault-injection regression added first on authoritative PR #92 test-only SHA `dcd3e86391e87f0cc9a7480a4bbaaf1d6bba6c9e`; both Ubuntu and Windows independently reproduced `Missing expected exception` in [CI #41208705450](https://github.com/eybersjp/development-kit/actions/runs/41208705450) (RED). The first source correction `369b8d0ddb67592aa7291e59e9cc344a85d754aa` made release failures explicit, but existing H36/H37/H38/H40 adversarial directory-swap tests exposed secondary lock-release errors masking the primary security failure (Ubuntu [CI #41208773372](https://github.com/eybersjp/development-kit/actions/runs/41208773372), RED). Narrow follow-up `78d9e22890671883c6566174502bfba9fac020c6` preserves the original transaction exception and attaches a secondary lock-release error; a successful durable append with failed release instead throws an explicit offline-recovery error warning that the commit may already be durable and must not be blindly retried. Ubuntu full v0.13 validation PASS in [CI #41208902511](https://github.com/eybersjp/development-kit/actions/runs/41208902511); Windows validation was still in progress when this memory entry was written. This memory-only update requires fresh final-SHA CI. Physical NTFS power-loss evidence (Issue #73), distinct exact-source independent code/architecture/security approvals, canonical persisted `ACCEPTED`, and protected v0.13 release approval remain outstanding. No gate waivers or merges. + + +- **T04-H61 lock initialization I/O failure (31 October 2026; NOT ACCEPTED):** A newly created exclusive state-lock file was left behind if its metadata write or fsync failed (EIO/ENOSPC), blocking later writers with an unowned lock. Isolated draft diagnostic [PR #141](https://github.com/eybersjp/development-kit/pull/141) added two fault-injection regressions first: SHA `a641b446cf9de9d93fdf2995d1fb0169ca81964d` reproduced both RED on Ubuntu and Windows in [CI #41204160210](https://github.com/eybersjp/development-kit/actions/runs/41204160210). Narrow fix records the exclusive lock inode, closes its descriptor, removes only the same single-link file after failed initialization, and flushes the parent; unexpected replacement/cleanup failures remain explicit offline-recovery errors. Diagnostic SHA `109a75667b0af1d871e6f7b013bd23f726738919` passed both complete Ubuntu/Windows validation jobs in [CI #41204273231](https://github.com/eybersjp/development-kit/actions/runs/41204273231). Only validated test and source files were ported to authoritative draft PR #92 in commits `176869e17cd3b9c21b2e5fdb18bd91111b821ecc` and `21c1ae757d3cfb993024c78261ac092d03e016b1`. This memory update changes the exact integration SHA, requiring fresh two-run cross-platform CI. No review approval, physical NTFS power-loss proof, ACCEPTED or release is claimed; Issues #71/#73/#84 and protected v0.13 gates remain binding. + + +- **T04-H60 fail-closed valid-event tail repair under ENOSPC (31 October 2026; NOT ACCEPTED):** `repairUnwitnessedTrailingLedgerSuffixLocked()` caught newline write/fsync errors alongside `JSON.parse` errors. A valid, hash-checked event missing only its final LF could be silently truncated if a newline write failed and the snapshot witness was absent. Test-only PR #92 SHA `8fb1e51aca3805b8f83935796b7d47cc5d95f266` reproduced RED on **Ubuntu and Windows** in [CI #41195907240](https://github.com/eybersjp/development-kit/actions/runs/41195907240). Narrow fix separates JSON syntax-error handling from validation and filesystem mutation, preserving valid canonical event bytes and propagating ENOSPC/EIO. Exact code SHA `f5f93e1bb7e350972cd7a8e11892501467724dcf` passed **both complete Ubuntu and Windows** validation jobs in [CI #41196089460](https://github.com/eybersjp/development-kit/actions/runs/41196089460); production acceptance failed closed with `Missing exact-source independent reviews`. Issue #84 contains the test-first evidence. This memory-only commit changes the PR #92 head and requires fresh exact-head CI. Still outstanding: physical NTFS sudden-power-loss Issue #73, genuine distinct code/architecture/security approvals, two final reviewed-source Ubuntu/Windows runs, persisted deterministic `ACCEPTED`, and protected v0.13 release authorization. No merges or waivers. + + +- **T04-A24 immutable GitHub reviewer identity (31 October 2026; NOT ACCEPTED):** The risk-3 gate and effective-review helper previously identified independent reviewers and their later decisive submissions by mutable GitHub login. An account rename could hide a later `CHANGES_REQUESTED` or stale reapproval and permit a historical approval to remain apparently effective; distinct role/author checks also used mutable login. Isolated unmerged diagnostic [PR #140](https://github.com/eybersjp/development-kit/pull/140) test-only SHA `8bf8c0eebb0db6b75752556d4b0e4d2cb8d02d20` reproduced T04-A24 RED on **both Ubuntu and Windows** in [CI #41194429027](https://github.com/eybersjp/development-kit/actions/runs/41194429027). Corrected diagnostic SHA `bbf54a07b6e7d8d31334eb833f66dec0cfa2d6f2` requires positive immutable GitHub numeric account IDs for reviewer provenance, distinct role identities, source-author exclusion and physical NTFS reviewer exclusion; both full Ubuntu/Windows validation jobs PASS in [CI #41194490767](https://github.com/eybersjp/development-kit/actions/runs/41194490767), with acceptance correctly skipped on diagnostic. The **exact validated source/test commit** was fast-forward ported to authoritative draft PR #92, with no diagnostic merge. This documentation update creates a newer integration head requiring fresh CI and source-bound review. **Still NOT ACCEPTED:** three genuine distinct exact-source role approvals, independently verified physical NTFS sudden-power-loss evidence/Issue #73 resolution, two complete final-head Ubuntu/Windows runs and authentic persisted deterministic `ACCEPTED`. PR #92 remains draft/unmerged and T04 excluded from v0.12. + + +- **T04-A23 physical NTFS reviewer revocation provenance (31 October 2026; NOT ACCEPTED):** On actual PR #92 source `017916a4662bd7f271e92e4548415d09aa93dd79`, the physical power-loss reviewer was checked only by a historical `APPROVED` receipt; a later `CHANGES_REQUESTED` or different-source reapproval could silently supersede it. Isolated draft diagnostic [PR #138](https://github.com/eybersjp/development-kit/pull/138) added test-first `T04-A23` in `813811dd68ef7ee5b7eb0782dd204da0aa2d20d8`; both Ubuntu and Windows reproduced RED at the required negative-control step in [CI #41190631145](https://github.com/eybersjp/development-kit/actions/runs/41190631145). Narrow fix `5718b62fb3219080c2b367c01634063927b0e199` applies the already tested authenticated latest-decisive-review guard to the separate physical NTFS approval; **both complete Ubuntu and Windows validation jobs PASS** in [CI #41190734296](https://github.com/eybersjp/development-kit/actions/runs/41190734296), diagnostic acceptance skipped. Only this tested gate change, its regression and this memory entry are ported to the authoritative draft PR #92. The physical NTFS power-loss test itself, Issue #73 closure, three distinct independent code/architecture/security approvals, two final-head full validation runs and authentic persisted `ACCEPTED` are **still missing**. No diagnostic or integration merge/release is authorized. + +- **T04-A22 authenticated security-control CI provenance (31 October 2026; NOT ACCEPTED):** Diagnostic draft [PR #136](https://github.com/eybersjp/development-kit/pull/136) branched from authoritative PR #92 source `35b1667d3e7c7dd9956c045aef18bac991fee082`. Test-only SHA `efd80c2068f180a1bed5337d7804887efce03e94` reproduced the risk-3 false-acceptance gap: an unrelated green same-SHA workflow could be cited as a security-control witness (Ubuntu [CI #41185875905](https://github.com/eybersjp/development-kit/actions/runs/41185875905), `T04-A22` RED). Narrow source correction at `08734c3148d4a8cd2b4f15fc66c47683732620c8` requires every control witness workflowRunId to belong to the already authenticated two full Ubuntu/Windows validation run IDs; both complete platform jobs PASS in [CI #41185891626](https://github.com/eybersjp/development-kit/actions/runs/41185891626). These test/source corrections are intended for authoritative PR #92, not diagnostic merge. Independent reviews, physical NTFS power-loss evidence and canonical ACCEPTED remain outstanding. + +- **W73-8DOT3 physical NTFS short-name alias regressions (31 October 2026; NOT ACCEPTED):** Source-bound test-only diagnostic draft [PR #134](https://github.com/eybersjp/development-kit/pull/134) forked authoritative PR #92 at `cd066b12166839396b7303ee3f1e1ffee377b541` and added two Windows `GetShortPathNameW` adversarial fixtures: (1) an 8.3-spelled ancestor cannot bypass an existing hash-chained restore journal after simulated lost publication; (2) two signed backup paths with long/short aliases cannot overwrite the same physical staged inode. Diagnostic test commit `81c395e166a9664dd3d1531916ba926b683a6816` passed **both full Ubuntu and Windows v0.13 validation jobs** in [CI #41182432970](https://github.com/eybersjp/development-kit/actions/runs/41182432970); Windows job explicitly reports both `W73-8DOT3-01` and `W73-8DOT3-02` **PASS**, not SKIP (the test would skip when the host has no 8.3 aliases). Only the validated test fixtures were ported to authoritative PR #92 at `b0716b403448697435e8c81585395d519051b6d0`; no runtime behavior or acceptance gate was changed. This evidence exercises physical NTFS alias identity on hosted Windows, **not** sudden physical power-loss durability. The remaining review threads require authenticated disposition; Issue #73 physical power-loss evidence, independent exact-source code/architecture/security approvals, two complete final-reviewed-SHA validations and persisted deterministic `ACCEPTED` remain outstanding. PR #92 stays draft/unmerged and diagnostics stay unmerged. + + +- **T04-H57 canonical ledger tail-repair descriptor race (31 October 2026; NOT ACCEPTED):** After layout/link preflight, a swapped hard link at the `events.jsonl` `r+` open allowed newline completion or torn-tail truncation to mutate an unrelated file. Added two fail-first `T04-H57` hard-link swap-at-open regressions directly on authoritative draft PR #92 in `2c8f9cc024768213d6d66ce650623daeead7336c`; **both Ubuntu and Windows** reproduced the unsafe repair in [RED CI #41180716611](https://github.com/eybersjp/development-kit/actions/runs/41180716611). Narrow runtime fix `4e969e2578b8c42ddc05c06ed304fae55baed78e` binds repair to the initial canonical inode, opens with descriptor no-follow where supported, requires single-link regular-file identity and unchanged descriptor bytes before any append/truncate, and rechecks canonical identity after fsync. **Both full Ubuntu and Windows validation jobs PASS** at this exact code head in [GREEN CI #41180784890](https://github.com/eybersjp/development-kit/actions/runs/41180784890); the independent risk-3 acceptance job correctly FAILS for missing exact-source independent reviews. Engineering replied with code/test/CI evidence and dispositioned **11 of 13** PR #92 review threads as fixed (not independent approvals); the two remaining OPEN findings concern genuine NTFS 8.3 ancestor journal alias and staged destination collision tests. Issue #73 controlled physical NTFS power-loss evidence, three distinct independent exact-source code/architecture/security approvals, two full validations on the final reviewed SHA, and persisted authentic `ACCEPTED` are still missing. This memory commit itself changes PR #92 HEAD; run fresh final-head CI. No merge, release, fabricated approval or physical-test claim. + + + +- **T04-SEC-JSON-01 lossless canonical JSON boundary (31 October 2026; NOT ACCEPTED):** `cloneSerializable()` previously accepted Map, Set, Date, undefined object values and non-finite numbers because successful `JSON.stringify()` was mistaken for lossless serializability. An acknowledged V2 event could therefore silently discard or transform state data. Isolated **draft, DO NOT MERGE** diagnostic [PR #132](https://github.com/eybersjp/development-kit/pull/132) introduced the negative-control test first at `3ae982c72fbee77bf38768f813e80547cf36a623`; both Ubuntu and Windows `Validate v0.13 State Engine V2 increment` failed as expected in [RED CI #41176319558](https://github.com/eybersjp/development-kit/actions/runs/41176319558) (first failure: Map was acknowledged without throwing). Narrow correction uses Node `isDeepStrictEqual` against the canonical JSON parse round-trip and rejects lossy state before ledger publication. Diagnostic SHA `728291c317eb7b009a1c8164437b7869b279052f` passed **both complete Ubuntu and Windows v0.13 validation jobs** in [GREEN CI #41176399858](https://github.com/eybersjp/development-kit/actions/runs/41176399858). Ported only the validated regression and runtime change to authoritative draft PR #92 in `e11da547b776c2cf306974535d1fd20f5ed6f7bb` and `5d39940177330274ba6e37033e1c316d08263487`. This memory update changes the exact integration SHA; fresh cross-platform validation and independent exact-source review are required. **Still NOT ACCEPTED:** 13 PR #92 review threads, distinct code/architecture/security approvals, controlled physical NTFS power-loss Issue #73 evidence, two complete identical-final-SHA CI executions and persisted authentic verdict. No merges, waivers or v0.12 T04 inclusion. + + +- **T04-A21 fail-closed persisted ACCEPTED artifact (31 October 2026; NOT ACCEPTED):** The v0.13 `t04_acceptance` workflow previously used `if: always()` and `if-no-files-found: ignore` for `.development-kit/acceptance/DKF130-T04.json`. A successful deterministic gate could therefore finish without retaining the canonical verdict; the hidden `.development-kit` directory also requires explicit artifact inclusion. Test-first draft diagnostic [PR #131](https://github.com/eybersjp/development-kit/pull/131) reproduced T04-A21 on **both full Ubuntu and Windows** in [RED CI #41175009428](https://github.com/eybersjp/development-kit/actions/runs/41175009428). Diagnostic commit `532181357ecef71777e49b62992d1372c8357c65` passed **both full platform validation jobs** in [GREEN CI #41175217226](https://github.com/eybersjp/development-kit/actions/runs/41175217226). Only the validated workflow and negative-control test were ported to authoritative draft PR #92 in `1f5a4f374405f39cace2d101125f9f1288e0c90f` and `089f30b6ad4a89f2c6e9cc82806f31f92c7b42a0`. Artifact upload now requires a successful gate, an exact-head name, explicit hidden-file inclusion, and failure if the verdict file is absent; the artifact is retained for 90 days and must be preserved in approved release evidence thereafter. **No ACCEPTED artifact exists yet**: distinct independent exact-source code/architecture/security approvals, 13 review-thread dispositions, controlled NTFS physical power-loss evidence (Issue #73), two complete final-head validation runs and the authentic persisted verdict remain required. Diagnostic PR #131 must not merge; neither PR #92 nor historical T04 branches may merge without protected v0.13 approval. + +- **T04-A20 active-writer temporary-file cleanup race (31 October 2026; NOT ACCEPTED):** `cleanupOrphanedStateTemps()` removed any file containing `.tmp-`, including a concurrently active writer's fsynced `events.jsonl.tmp--` stage, so publication could fail after preparation. Isolated draft diagnostic [PR #130](https://github.com/eybersjp/development-kit/pull/130) added an active-PID staging regression; both Ubuntu and Windows reproduced the defect in [RED CI #41171427038](https://github.com/eybersjp/development-kit/actions/runs/41171427038). The first implementation failed the existing abandoned-temp regression because of over-escaped regexes ([CI #41171525251](https://github.com/eybersjp/development-kit/actions/runs/41171525251)); corrected exact diagnostic SHA `54162254d1a7c57c5d807f2385da760bed91a9b9` passed complete Ubuntu and Windows validation in [GREEN CI #41171601007](https://github.com/eybersjp/development-kit/actions/runs/41171601007). The narrowly scoped source and regression were ported to authoritative draft PR #92 in commits `106847dd1afb38b29fbe419bacf292753d54e5ba` and `dddf27d18a8533a5f3541339bffa1958f6d98e25`. Cleanup now retains live or unknown PID-owned stages, restricts deletions to internal temporary names, uses lstat and preserves hardlink witnesses. Production-head CI, independent review, physical NTFS power-loss evidence, unresolved threads and risk-3 ACCEPTED are still pending. PR #130 remains unmerged diagnostic only. + +- **T04-A19 effective GitHub role-review provenance (31 October 2026; NOT ACCEPTED):** The risk-3 gate could authenticate an old `APPROVED` review receipt while ignoring a later `CHANGES_REQUESTED`, `DISMISSED`, or reapproval bound to a different source SHA from the same reviewer. Isolated draft diagnostic [PR #129](https://github.com/eybersjp/development-kit/pull/129) added adversarial T04-A19 tests on PR #92 source `37b97ba7644888ad92371cde3261ae5062ffb8f3`; **both Ubuntu and Windows** reproduced the missing effective-review guard in [RED CI #41163645280](https://github.com/eybersjp/development-kit/actions/runs/41163645280). A narrow authenticated, paginated PR review-timeline guard now requires the latest decisive review by each independent role identity to be the exact approved source-bound receipt; later comments do not revoke an approval. Documentation-index regression in the first candidate was corrected; diagnostic final SHA `74a5233c7a05c28b88d1e881e24c56615bccb48a` passed **both complete Ubuntu and Windows validation jobs** in [GREEN CI #41164008154](https://github.com/eybersjp/development-kit/actions/runs/41164008154); diagnostic acceptance correctly skipped. Only the validated test, helper, gate and required documentation were ported to authoritative PR #92 through `7c1d04ee5cc690b9e1763791769c1f11aba2f736`. Fresh integration-head CI and exact-source reviews are required. PR #129 remains diagnostic and must not merge. Thirteen unresolved PR #92 review threads, three independent code/architecture/security approvals, controlled physical NTFS power-loss Issue #73 evidence, two full same-final-source validation runs and an authentic persisted `ACCEPTED` verdict remain mandatory; no T04 merge/release is authorized. + + + +- **T04-A18 authenticated validation workflow provenance (31 October 2026; NOT ACCEPTED):** Risk-3 gate previously accepted any two same-SHA GitHub PR workflow executions with jobs named `validate (ubuntu-latest)` and `validate (windows-latest)` even if a different workflow skipped mandatory v0.13 checks. Isolated draft, do-not-merge diagnostic [PR #126](https://github.com/eybersjp/development-kit/pull/126) added a fail-first contract test; **both Ubuntu and Windows** reproduced the missing workflow identity and mandatory step-success checks in [RED CI #41150895862](https://github.com/eybersjp/development-kit/actions/runs/41150895862). Narrow gate correction requires authenticated run `path` `.github/workflows/ci.yml`, repository `eybersjp/development-kit`, and successful mandatory State Engine V2, full compatibility regression and risk-3 contract steps in **each** OS job. Diagnostic SHA `20c8f6072f9d4f3898c8f96956d35c1953c8f9b0` passed both complete Ubuntu/Windows validation jobs in [GREEN CI #41151290304](https://github.com/eybersjp/development-kit/actions/runs/41151290304); diagnostic acceptance correctly skipped. Only the validated gate/test were ported to authoritative PR #92 in `d0256f6f1915b98e5753f85000f3ec6e2d7fa9ff` and `d558d588a2796e9c230960d3338a7e65834e77ce`; diagnostic PR #126 must not merge. Fresh two-run exact-final-head validation is required after this memory update. The 13 unresolved review threads, distinct exact-source code/architecture/security approvals, physical NTFS sudden-power-loss evidence under Issue #73, and authentic persisted `ACCEPTED` remain mandatory. Neither T04 nor diagnostics may merge without protected v0.13 release approval. + +- **T04-A17 exact-source CI provenance (30 October 2026; NOT ACCEPTED):** PR #92's `validate` matrix previously checked out GitHub's synthetic PR merge ref, not the immutable reviewed PR head. GitHub Actions PR #92 run #41127165486 checked out merge `25302739265ee4e4335ead869f81ecfefae96500` instead of PR head `f3bc71988cc8a472aaeabefc2397ebe5db4c1d85`; diagnostic PR #123 run #41127572518 checked out merge `0cc2e65` instead of that same head. Draft, do-not-merge diagnostic [PR #124](https://github.com/eybersjp/development-kit/pull/124) added the T04-A17 exact-checkout negative control; both full Ubuntu and Windows jobs reproduced its failure in [RED CI #41130880458](https://github.com/eybersjp/development-kit/actions/runs/41130880458). Narrow workflow fix sets `actions/checkout@v7` in the shared validation matrix to `${{ github.event.pull_request.head.sha || github.sha }}` and `persist-credentials: false`; the diagnostic exact-head `5fd3641078258be5897d4ca186ed346755ca6a97` passed **both complete Ubuntu and Windows v0.13 validation jobs** in [GREEN CI #41131224727](https://github.com/eybersjp/development-kit/actions/runs/41131224727), with job logs confirming checkout of `5fd3641` and T04-A17 PASS. Only the validated test and checkout fix were ported to actual integration PR #92 in `333d2f82c870563a7268a83df0f03217d7668e42` and `7502e41d03497b7fd3b185018745bb6cef1f57a1`; the diagnostic PR trigger was not ported. Fresh production-head CI is required. Previous same-SHA runs tested merge sources and must not be represented as two **exact-source** validation executions. Issue #73 physical NTFS power-loss evidence, 13 unresolved independent-review threads, three authentic exact-source approvals and persisted risk-3 ACCEPTED remain blockers. No merge or release is authorized. + +- **T04-H56 pending-recovery snapshot-witness I/O fail-closed (30 October 2026; NOT ACCEPTED):** Actual integration draft PR #92 inherited a recovery path that treated `EACCES`/I/O failure reading the materialized snapshot witness as if it were a disposable invalid snapshot, permitting canonical ledger truncation/repair and pending-journal acknowledgement without inspecting a potentially newer witness. Isolated draft diagnostic [PR #122](https://github.com/eybersjp/development-kit/pull/122) used descriptor-relative-path-aware `T04-H56` fault injection and reproduced the missing fail-closed rejection on **Ubuntu and Windows** in [RED CI #41126196799](https://github.com/eybersjp/development-kit/actions/runs/41126196799). Narrow correction separates filesystem read errors from JSON/derived-schema corruption: only `ENOENT` permits absent-witness recovery; other read errors abort before any canonical mutation, preserving original ledger and pending journal. Diagnostic `ee0e5f69b9f619b87a694bb3f93d3790873323c2` passed both complete Ubuntu/Windows v0.13 compatibility lanes in [GREEN CI #41126337280](https://github.com/eybersjp/development-kit/actions/runs/41126337280). Only regression test `77bfac8f398b733f16ebf4ea55f272e3c96d0069` and runtime correction `a2dc909cb69f5e73acfd1a038f9c8272a7e2c94a` were ported to PR #92; diagnostic PR #122 must not merge. Production-head CI and exact-source independent reviews must be rerun; NTFS physical sudden-power-loss Issue #73, unresolved PR threads, and authentic persisted risk-3 ACCEPTED remain blockers. + +- **Risk-3 deterministic acceptance enforcement (30 October 2026, NOT ACCEPTED):** actual integration draft PR #92 now includes a distinct source-bound `t04_acceptance` GitHub Actions job depending on both complete Ubuntu/Windows validation jobs, with read-only authenticated review/CI provenance, pending receipts, physical NTFS evidence requirements, canonical `decideAcceptance` and uploaded verdict artifact only on genuine success. Diagnostic draft PR #121 first reproduced the absent gate in RED Ubuntu/Windows CI #41101076526; corrected diagnostic SHA `6e228fb02f29f07131f651fc4f7077ddc1f39ab6` passed both full platform lanes in CI #41116732941 (acceptance correctly skipped on diagnostic). The PR #92 documentation-index regression was reproduced in CI #41118003732 and fixed at `2490b27248d612904415240be8dbc656eee1da51`; append-only gate workflow ported at `6ffa5a6e2dd4c7f1f7f162921ab1481611b04093`. A historical regression overreached by rejecting the intentionally branch-restricted acceptance job; RED Ubuntu CI #41119009453 and previously validated narrow test correction ported at `a40b3186f38a52c3d66cda2e6b8b26cdd508417f`. Production-head CI #41119119763 was in progress at this checkpoint, **not** claimed green. The independent gate must fail closed while 13 PR #92 threads remain unresolved, three distinct exact-source code/architecture/security approvals are missing, Issue #73 lacks controlled physical NTFS sudden-power-loss evidence, and two distinct complete same-final-SHA runs and a genuinely persisted `ACCEPTED` are absent. PR #92 remains draft/unmerged; no T04 v0.12 merge or v0.13 release is authorized. Repository Issue #84 records source/CI provenance in comment #6532264975. + +- **T04-P2 literal JSON patch-key compatibility (30 October 2026):** PR #92 exact-source review on `952ae0dec1ae7924286c37d3567ec571689efea7` identified that internally generated patch paths rejected valid JSON keys including empty strings, LF, CR and NUL. Isolated **draft, do-not-merge** diagnostic [PR #119](https://github.com/eybersjp/development-kit/pull/119) reproduced the defect with adversarial update/deletion tests on **both Ubuntu and Windows** in [CI #41088497647](https://github.com/eybersjp/development-kit/actions/runs/41088497647) (RED). The narrow correction permits any literal string segment in JSON array paths while retaining type validation and own-property-safe patch writes. Diagnostic `51f80f691d485e47dd201a42a3ae64545d1771fb` passed **both full Ubuntu/Windows v0.13 validation jobs** in [CI #41088653146](https://github.com/eybersjp/development-kit/actions/runs/41088653146) (GREEN). Only runtime and test were ported to actual v0.13 PR #92 in `f35455dff057cd550eccc4d0100f54292b65d711` and `b6a25cbc510dd8011fc43319dc7927f71b2b42c2`; diagnostic workflow exception was **not** ported. This does not establish independent acceptance or NTFS physical power-loss durability. + +- **T04-P2 non-mutating legacy Control Center projection (30 October 2026):** PR #92 exact-source review identified that a V2 metadata-only ledger plus unmigrated legacy workflow could cause read-only `GET /v1/scale-context` to repair a corrupt `current.json` pointer. Isolated **draft, do-not-merge** diagnostic [PR #120](https://github.com/eybersjp/development-kit/pull/120) reproduced unauthorized pointer repair and tested a valid unmigrated legacy pointer; **Ubuntu and Windows** [CI #41088803827](https://github.com/eybersjp/development-kit/actions/runs/41088803827) RED. Narrow correction distinguishes active V2 workflow authority from V2 metadata presence; unmigrated legacy workflows use the existing non-mutating pointer projection, and unreadable V2 migration evidence fails closed rather than authorizing fallback. Diagnostic `b9374618c054c1fd9b596b68b43ac402d954bf01` passed **both full Ubuntu/Windows v0.13 validation jobs** in [CI #41089162418](https://github.com/eybersjp/development-kit/actions/runs/41089162418) (GREEN). Only runtime/test were ported to actual v0.13 PR #92 in `fc1d6715d95cd530ed17fc2b3f1ae801269b0854` and `a28b3cce36c57323a9cabc8d291ed1cf1a26a951`. This does not establish independent acceptance or physical Windows power-loss assurance. + +- **Acceptance boundary after both fixes:** actual PR #92 must complete fresh full Ubuntu/Windows CI on the **combined final head**, two distinct complete runs on the same immutable reviewed SHA, genuine distinct authenticated code/architecture/security reviews with all major threads resolved, controlled physical NTFS sudden-power-loss evidence for Issue #73, and persisted deterministic risk-3 `ACCEPTED`. Neither diagnostic PR #119/#120 nor historical PR #70 is authorized to merge; no release or approval is claimed. + +- **T04-P2 v0.13 Control Center API projection compatibility (30 October 2026):** exact-source independent review of PR #92 found that `/v1/workflow` reads canonical V2 while `/v1/scale-context` still reads stale v0.12 legacy `current.json`, reporting an active fresh workflow as inactive and a migrated workflow as frozen. Isolated draft diagnostic [PR #118](https://github.com/eybersjp/development-kit/pull/118) test-only `f9a1972ccfcc5e7b2d580612ef9a94b63627aa46` reproduced the mismatch in Ubuntu [CI #41080813268](https://github.com/eybersjp/development-kit/actions/runs/41080813268) (RED). A narrow read-only authority-aware API projection prefers a verified active V2 layout, refuses stale legacy fallback when V2 metadata is invalid, preserves v0.12 legacy behavior for non-V2 projects, never creates a legacy pointer, and reports V2 as installed but **not independently accepted**. Explicit v0.13 contract scope expanded to `runtime/api/runtime-api-service.mjs`. Diagnostic SHA `85b8628fcc8ecc0b47eb7fa4d1a22c19e3996932` passed **both complete Ubuntu and Windows v0.13 validation jobs** in [CI #41081063299](https://github.com/eybersjp/development-kit/actions/runs/41081063299). Ported only validated API source, regression and scope to actual PR #92 in `4fe2fb7ab28a3c05d5b248e9dbdaf68b7b7567a2`, `00d5628beee97a5d1131ba1f726517530c51d2a5`, `e0b381d3861a81db7d8c866e83a28e8cd184baaa`. PR #118 remains draft, unmerged; the new exact integration head requires fresh CI and independent source-bound re-review. No physical Windows NTFS power-loss certification, risk-3 ACCEPTED verdict, mainline merge or release is claimed. + +- **W73-28 staged restore path alias hardening (30 October 2026):** draft, unmerged diagnostic [PR #116](https://github.com/eybersjp/development-kit/pull/116) introduced a signed-backup regression using distinct `s.json` and Unicode U+017F long-s paths. Existing lowercase-only normalized destination preflight silently accepted the pair on Ubuntu, reproduced as `Missing expected exception` in test-only [CI #41079128579](https://github.com/eybersjp/development-kit/actions/runs/41079128579) and [#41079372395](https://github.com/eybersjp/development-kit/actions/runs/41079372395) (RED). The narrow correction conservatively checks uppercase-folded destinations before staging and checks staged file dev/inode identities before replacing an existing destination, addressing additional NTFS 8.3/volume-specific aliases where textual folding is insufficient. Diagnostic SHA `d849880ece9a58f7bf9f56feab02882c028811c9` passed both full Ubuntu and Windows v0.13 validation lanes in [CI #41079431166](https://github.com/eybersjp/development-kit/actions/runs/41079431166) (GREEN). Only test and runtime files were ported to actual integration PR #92 in `5920cd352dae245d6360a59fd62672c1259bccb9` and `ea32b2bbff336f6dde4f581c2315b58f4a7e86b0`. The real NTFS 8.3 alias variant and sudden physical power-loss durability still require independent evidence; this test is a conservative cross-platform rejection control, **not** physical NTFS certification or T04 ACCEPTED. PR #116 must not merge. + +- **T04-P2 pending suffix validation before recovery mutation (30 October 2026):** independent code review on PR #92 SHA `73603f8a2004100b0fb8e302f89825eecc5638b5` found that `recoverPendingCommitLocked()` could truncate/append/fsync canonical bytes before checking a hash-invalid pending event or conflicting snapshot witness. Isolated draft diagnostic [PR #117](https://github.com/eybersjp/development-kit/pull/117) test-only `5d02b5d03b46f9e24a637f4a1696fc9e407ab20c` reproduced evidence destruction with both zero-byte and partially appended suffix fixtures in Ubuntu [CI #41079260840](https://github.com/eybersjp/development-kit/actions/runs/41079260840) (RED). Narrow source correction moves full proposed-ledger hash-chain validation and snapshot-witness checks before any canonical mutation; diagnostic SHA `e884d88fb3a11c59acc77f159dea209de4cd730e` passed both complete Ubuntu and Windows v0.13 lanes in [CI #41079450641](https://github.com/eybersjp/development-kit/actions/runs/41079450641). Ported only validated runtime/test to actual PR #92 in `385f7c9edc3fd2e882edcc31589f6c20de467124` and `130bb5c7ff05a2762bbf0a20b3ba2f444742e04a`; new combined-head CI and exact-source re-review remain mandatory. PR #117 remains draft, unmerged. The other new P2 review finding is stale `/v1/scale-context` API projection after V2 cutover, which needs a separate compatibility correction without reintroducing legacy `current.json` for fresh V2 projects. No physical NTFS power-loss result or independent risk-3 ACCEPTED verdict is claimed. + +- **T04-P2 composite index identity collision (30 October 2026):** actual integration PR #92 review identified a collision between valid identities (`a::b`, `c`) and (`a`, `b::c`), leaking records across contract/task/run/lifecycle filters. Isolated draft diagnostic [PR #114](https://github.com/eybersjp/development-kit/pull/114) reproduced the defect with test-only SHA `85a97cfc55e17c3ef36c1364619330f04b73cf0c`: Ubuntu and Windows CI [#41074686949](https://github.com/eybersjp/development-kit/actions/runs/41074686949) RED. Follow-up stale-index test `cfe3656dd95a0cce68d73734171350757d60799d` reproduced a second failure on Ubuntu in [#41074990296](https://github.com/eybersjp/development-kit/actions/runs/41074990296): a legacy derived index with matching source hash was still trusted. Diagnostic SHA `dbba5619b783f8c2b5451f24766410847728b4e2` passed both complete Ubuntu/Windows v0.13 CI lanes [#41075160424](https://github.com/eybersjp/development-kit/actions/runs/41075160424). The narrow correction uses collision-free JSON tuple keys and compares loaded disposable indexes to the canonical rebuilt index, rebuilding stale mappings before serving queries. Ported only the verified runtime/test changes to actual PR #92 as `57744d530ee38d457dc945d01982c08cd4cacd83` and `8dff8e451ab3cbebdefec7d9daeca8707112ccd2`; production source/test SHA `8dff8e451ab3cbebdefec7d9daeca8707112ccd2` passed both full Ubuntu/Windows v0.13 jobs in [CI #41076212580](https://github.com/eybersjp/development-kit/actions/runs/41076212580); the later combined head requires its own fresh CI. PR #114 remains draft, never merge. This is not T04 ACCEPTED, does not resolve Windows physical power-loss evidence or independent risk-3 reviews. +- **T04-P2 interrupted bootstrap hard-link investigation (30 October 2026):** isolated draft [PR #115](https://github.com/eybersjp/development-kit/pull/115) reproduced an orphaned `schema.json.tmp-...` hard link after process death between atomic non-replacing link publication and staging unlink: Ubuntu CI [#41075297478](https://github.com/eybersjp/development-kit/actions/runs/41075297478) RED, normal reads fail on nlink=2. A narrowly confined candidate validates an exact initial bootstrap image, same inode/dev, precisely one private staging link and dead owner before unlink/flush; unrelated and live-owner links fail closed. Ubuntu diagnostic green at `c305318d6e00368bce37008500bc423741ac28b4`, but Windows [#41075671505](https://github.com/eybersjp/development-kit/actions/runs/41075671505) exposed H03/H15 writer-lock timeout under the additional normal-path filesystem probes. Optimized candidate `522c8b617f99051e655167f2f5754131b60709a7` probes for orphaned staging links **only when a multiple hard link is actually observed**; passed both full Ubuntu/Windows v0.13 jobs in [CI #41076958389](https://github.com/eybersjp/development-kit/actions/runs/41076958389), including H03/H15 Windows writer-lock concurrency. Ported only the validated bootstrap source/test changes to actual PR #92 in `879d77379de868064bfab6dae8efc5b7b873f053` and `410298fda1c8027a214bb2a235f56442ea986a27`; the diagnostic PR #115 remains draft and unmerged. The combined final PR #92 source requires fresh exact-head CI, external reviews and real NTFS sudden-power-loss assurance. Do not weaken concurrency or claim physical power-loss durability. + +- **T04-P1 entity dictionary / P2 atomic schema publication (30 October 2026):** isolated diagnostic draft [PR #112](https://github.com/eybersjp/development-kit/pull/112) reproduced two distinct PR #92 review findings with test-only commit `8fbc216705e2f4bf736ef63cf160a163cc5181cd`. GitHub Actions [#41066475740](https://github.com/eybersjp/development-kit/actions/runs/41066475740) failed on **both Ubuntu and Windows**: inherited `__proto__`/`constructor` entity identities were misclassified as existing entities, and a second initializer observed a published but empty `schema.json`. The narrowly scoped correction uses own-property-safe entity buckets/lookups and index references, and prepares/fsyncs non-empty initial schema/snapshot/index privately before atomic, non-replacing hard-link publication; empty canonical ledger creation remains exclusive and non-replacing. Diagnostic SHA `16cd69e20a443eb75689bb3bc30fc9237bfb7f9a` passed the **complete Ubuntu and Windows v0.13 compatibility/regression jobs** in [CI #41066604563](https://github.com/eybersjp/development-kit/actions/runs/41066604563). Ported **only** the validated `scripts/state-engine-v2.test.mjs` and `runtime/orchestration/state-engine-v2.mjs` changes to actual PR #92 in commits `7c199d0dc637663adee27a14428507c88ebbcd49` and `e75c6225f1090225504c68815f56035e59f462a8`. PR #112 stays draft and must not merge. These tests do not prove Windows NTFS physical sudden-power-loss durability, independent source-bound reviews or deterministic risk-3 acceptance; Issue #73 and unresolved review threads remain blocking. New PR #92 source/memory commit requires fresh exact-head CI and authentic re-review. + +- **T04-CI-01 source-bound v0.13 CI routing (30 October 2026):** actual integration PR #92 previously selected v0.13 full compatibility regression only when `github.head_ref` exactly equalled `feature/v0.13-state-engine-v2`. On a `main` push or renamed PR branch this condition could skip T04 validation despite the canonical runtime being present. Isolated diagnostic draft [PR #111](https://github.com/eybersjp/development-kit/pull/111) reproduced the branch-name bypass with a test-only regression in Ubuntu [CI #41062682013](https://github.com/eybersjp/development-kit/actions/runs/41062682013) (RED). A first source-bound correction passed Ubuntu/Windows [CI #41062788215](https://github.com/eybersjp/development-kit/actions/runs/41062788215) (GREEN), but the follow-on adversarial test proved that keying on the *contract file* alone allowed deletion of that contract to bypass T04 validation; [CI #41063850666](https://github.com/eybersjp/development-kit/actions/runs/41063850666) (RED). Final diagnostic SHA `a21f91da7e62e88df7358e5c2153818800049938` keys all five conditional v0.12/v0.13 steps on the **canonical runtime file** and requires the v0.13 contract whenever that runtime exists; both full Ubuntu/Windows v0.13 lanes passed [CI #41064093123](https://github.com/eybersjp/development-kit/actions/runs/41064093123). Only the validated `.github/workflows/ci.yml` and `scripts/v071-regression.test.mjs` changes were ported to PR #92 in commits `fb25ff00fb3306f3ff5990c85c771291af12d328` and `a9d114f17ccb56be1795024f7b2cfa943d34b686`. The diagnostic PR remains draft and must not merge. **This is not T04 ACCEPTED:** PR #92 needs fresh exact-head CI, two distinct full validations on the eventual final reviewed SHA, authentic independent role reviews, closure of all unresolved P1 threads and Issue #73 physical NTFS sudden-power-loss evidence. Neither T04 nor v0.13 was merged or released. + +- **T04-P1 prototype-safe patch replay (30 October 2026):** actual integration PR #92 independent review found inherited-property traversal in `diffValues`/`setAtPath`/`deleteAtPath`. Isolated diagnostic draft PR #109 test-only commit `692084d0b02194a39f563e1f90705077eb771f75` reproduced process-wide `Object.prototype` pollution in Ubuntu/Windows CI [#41057142898](https://github.com/eybersjp/development-kit/actions/runs/41057142898). Correction `7d55832a640400948d7cf896bcc18b822c223471` uses `Object.hasOwn` traversal and `Object.defineProperty` for literal JSON keys; both full v0.13 Ubuntu and Windows lanes passed [#41057264710](https://github.com/eybersjp/development-kit/actions/runs/41057264710). Only source/test changes were ported to PR #92 in commits `e92c2fd1df681751c2ff70e565ba5c5e1beaf731` and `42df7f1f58ba899e817618964a5975b5f8ace52f`; diagnostic CI exception was not ported. +- **T04-P1 first-layout concurrent ledger loss (30 October 2026):** PR #92 review found `ensureStateEngineLayout` could rename an empty initial ledger over an already acknowledged concurrent writer. Isolated diagnostic draft PR #110 corrected its deterministic Linux descriptor-relative interleaving fixture at `079a92a9a8b8d9b379a7b774d5edcc2790c91d74`; test-only Ubuntu/Windows CI [#41057459611](https://github.com/eybersjp/development-kit/actions/runs/41057459611) reproduced loss of the acknowledged event (empty `events.jsonl`). Correction `d1b1d9e32e1ac74e4b8ee98fd7a1fdb7c6a79915` creates initial layout entries exclusively with pinned-directory identity and fsync barriers, never replacing an existing writer-created ledger. Both full Ubuntu and Windows v0.13 validation lanes passed [#41057594721](https://github.com/eybersjp/development-kit/actions/runs/41057594721). Only source/test changes were ported to PR #92 in commits `99fe01dd428f48bb7b4499213dbadde27b028f61` and `f9fde7c4ea56c1bd2be1a373c0ad7f9fcbb4337c`; diagnostic CI exception was not ported. +- **Acceptance boundary:** the two diagnostic green runs validate separate source corrections, not final combined source acceptance. PR #92 must pass fresh exact-head full Ubuntu/Windows runs and independent distinct code/architecture/security review after this memory update; Issue #73 still requires controlled physical NTFS sudden-power-loss evidence. Do not merge, release or persist `ACCEPTED` without every risk-3 gate. + + +- **W73-27 Windows ancestor alias journal-discovery hardening (30 October 2026):** diagnostic draft PR #108, test-first. Real Windows NTFS 8.3 short names were unavailable on the hosted runner (initial CI #41051765992 reported an explicit skip), so the test was extended to the alternate Windows extended-length namespace spelling of the *same verified physical parent*. Test-only CI #41052720876 reproduced a missing alias-journal rejection on Windows (the second restore reached unsafe-path preflight and failed with `EISDIR` instead of identifying the retained interrupted journal); Ubuntu passed. The corrected guard compares canonical physical parent/root identity using `fs.realpathSync.native`, including a missing target root after displacement, before allowing a new transaction. The diagnostic correction passed both complete Ubuntu and Windows v0.13 compatibility/regression jobs in [CI #41053285824](https://github.com/eybersjp/development-kit/actions/runs/41053285824). Only source and W73-27 regression test were ported to integration PR #92 in commits `f372d2ad52f1446f978be83fa19e50b3a999ba94` and `d78a98e7ac336bf1c5b2e22cd1ad2a9f211bec4a`; diagnostic CI workflow was **not** ported. **Limits:** extended-length alias behavior was exercised, not an actual NTFS 8.3 alias on this host; no physical sudden-power-loss durability is established, and this does not resolve Issue #73 or constitute independent risk-3 acceptance. Exact integration-head CI and independent source-bound reviews remain mandatory. + +- **W73-25 native-helper path binding (30 October 2026):** Draft diagnostic PR #105 reproduced an unbound `Probe` path acknowledgement on exact test-only source in Ubuntu CI #41042850430 (RED, missing expected exception). The initial correction exposed genuine Windows 8.3 ancestor-alias and retained V1 journal-file rename compatibility failures in CI #41042984647 and #41043503806; these were corrected using live parent-directory identity for a renamed source and exact file/directory inode identity for published destinations. Final diagnostic commit `49c9abf0de43d50c2ca8748c2c173e51f0353275` passed the complete Ubuntu and Windows v0.13 validation lanes in CI #41044118361. Only the validated runtime and W73-25 regression test were ported to actual draft integration PR #92 as commits `6b89d9dba299b05c438480c647038590ba2d27e9` and `d32125cbcc7aa7b91119d80224eb575d948684d9`; diagnostic workflow exceptions were not ported. The helper now rejects successful responses for a different requested storage-profile, flush, source, destination or parent-flush path. This is API/path-binding evidence, **not physical NTFS power-loss proof**. Exact-head PR #92 CI, distinct independent role reviews and persisted deterministic risk-3 `ACCEPTED` remain outstanding. Issue #73 remains OPEN. + +- **30 October exact-source verification checkpoint:** integration PR #92 head `1f2c909816287aeceb7aca1f2fd4e6dd9a68aadf` passed GitHub Actions CI #41030766378 on **both Ubuntu and Windows** (two platform jobs in one workflow execution; NOT two independent full runs). The diagnostic PR #99 head `51556f841bb07e9ed27436e9156bfff022487de1` passed both platform jobs in CI #41015133002; the diagnostic head is divergent and must not be merged as a substitute for PR #92. W73-21/23/24 NTFS ACL and helper-identity corrections are integrated on PR #92. Current acceptance blockers remain **controlled NTFS physical power-loss evidence under Issue #73, distinct authenticated exact-source code/architecture/security reviews, repeated complete platform validation on the final reviewed SHA, and persisted deterministic risk-3 ACCEPTED**. No acceptance, release or merge is asserted. Updating this file creates a new source commit and therefore requires fresh CI and review binding. + +- **NTFS DACL private-stage hardening (30 October, W73-21/23/24):** diagnostic draft PR #104 reproduced missing ACL preservation and unverified native-helper responses on exact negative-control source (CI #41029403553). The v0.13 Windows helper now creates a maintenance-only protected stage at creation and retains its private root DACL throughout sensitive file copy; descendant original DACLs are restored post-order and whole-tree verified before the original root DACL is installed last. Native helper result checks bind operations to the actual directory identity (including verified NTFS 8.3 short-path aliases) and reject false/mismatched results. Focused real Windows NTFS W73-21/23/24 plus legacy restore fixtures passed with zero skips in CI #41029930153; Ubuntu full validation passed in CI #41029930369, with Windows full compatibility still pending at the time of this memory update. Ported only source/test files from diagnostic PR #104 into integration PR #92 through commit `11c2843bef8b8363fa39ff248ead38de705ebef0`; diagnostic workflow exceptions were NOT ported. **This is not NTFS physical power-loss proof or risk-3 ACCEPTED.** Issue #73 and three-role independent reviews remain blocking. + +- **Actual integration candidate:** draft PR #92, branch `feature/v0.13-state-engine-v2`, based on released v0.12.1. Historical PR #70 and diagnostics #94–#99 are **not** release merge targets. +- **Test-first diagnostic:** PR #99 reproduced the Windows NTFS case-alias restore-journal bypass (W73-19, CI #41010567167) and orphan displaced-original bypass after journal loss (W73-20, CI #41015062706). Both failed as expected before their narrow corrections. +- **Validated candidate:** PR #99 commit `51556f841bb07e9ed27436e9156bfff022487de1`, CI #41015133002: Ubuntu and Windows v0.13 regression lanes PASS. Earlier W73-19-only candidate also passed both platforms in CI #41014994052. Hosted CI establishes API/test behavior, **not physical NTFS power-loss durability**. +- **Ported to PR #92:** commit `37d1d22d94d2fdf005c35bb1d807c059883fc50b` carries 11 verified source/test/documentation files, excluding PR #99's diagnostic-only workflow exception. Exact-head CI #41015777874 subsequently passed both Ubuntu and Windows v0.13 lanes. +- **Windows helper result contract W73-22 (test-first, 30 October):** draft diagnostic PR #102 at negative-control `ab10a41f8292d7bf0f4b1e572f709002c5b5984c` failed Ubuntu and Windows in CI #41021958044 because the helper-response verifier was absent; corrected diagnostic `7cb6ca4d04fc5dd651bc4050bb4c5128255ade11` passed both full v0.13 lanes in CI #41022103181. The source/test-only correction was ported to PR #92 in commits `4740afdfa7026dde02296dedaa73c4405111f794` and `8457a59be5e02412393515188d4601757abd0e9e`. Exit-zero native helper results now require affirmative operation-specific flush/rename flags and supported NTFS profile. PR #102 is diagnostic only; its CI workflow exception was not ported. Fresh exact-head PR #92 CI and independent review remain required. This is API result verification, **not** physical power-loss proof. +- **Safety boundary:** retain original directories, hash-chained restore journals, full Windows legacy-backup restoration, fail-closed publication and recovery, exact-source provenance and v0.12.1 compatibility. +- **Outstanding blockers:** Issue #73 Windows physical crash-durability evidence; independent P1 review findings on Windows DACL inheritance/preservation and recovery publication-barrier retry; Issues #71/#84 acceptance and distinct independent code, architecture and security approvals. No `ACCEPTED` verdict, merge or release is claimed. +- Older v0.11/v0.12 sections below preserve their historical engineering snapshots; their then-current release-status statements do not supersede the v0.12.1 baseline above. + ## v0.11.2 Reliability Controls - Development Contracts with authoritative-source fingerprints. @@ -190,3 +287,27 @@ Integrated through PR #54; Issue #51 is completed. - DKF remains general-purpose and provider/host/domain neutral. No application-specific cache or capsule semantics entered core. - v0.12.0 remains unreleased; package version remains 0.11.2; T03 acceptance does not merge to `main`, tag, publish, or release. - Next validated increment: **DKF120-T04 — State Engine V2**. T04 requires its own Development Contract before production implementation. + +- **T04-H66 source-bound witness hardening (31 October 2026; NOT ACCEPTED):** Exact PR #92 SHA `3316ff1259da0d6b537a9bcc70de9e6e41d39fc5` was found by genuine independent Codex review to allow a reader-triggered index rebuild to overwrite later acknowledged history, and pending recovery to ignore conflicting older witness hashes. Isolated draft diagnostic PR #148 test-only SHA `833680235862671ec7c9815a54ba6f1f5758f778` reproduced H66 failures on Ubuntu and Windows in RED CI `41226287003`. Narrow fix SHA `b9a13b6aa3f42fffb15371c906ef0383bd28fd38` passed complete Ubuntu and Windows validation in GREEN CI `41226384214`. Compare each derived witness hash with the canonical event at the witness's own sequence; check both witnesses before rebuilding an index; fail closed on unreadable evidence. Exact-source independent reviews, physical NTFS power-loss assurance and risk-3 ACCEPTED remain outstanding; no merge or release authorized. + +- **T04-H67 read-only index verification correction (31 October 2026; NOT ACCEPTED):** Diagnostic PR #150 at exact test-only SHA `b1f7a2648ed65baab4e3c4fd46e6a27dab452558` reproduced on both Ubuntu and Windows in RED CI `41231620079` that `loadStateIndex({ rebuildIfNeeded: false })` silently rebuilt a missing snapshot. Narrow source correction `11f7b12b0974e1535877cc7ed2e54368bc798a54` honors the caller's no-rebuild boundary, returns null without publishing derived state when the source snapshot is absent, and preserves explicit rebuild behavior. Both complete Ubuntu and Windows validations PASS in GREEN CI `41231751410`. The test/source changes are confined to `runtime/orchestration/state-engine-v2.mjs` and `scripts/state-engine-v2.test.mjs`. Three distinct exact-source independent role approvals, controlled physical NTFS power-loss evidence, persisted risk-3 ACCEPTED and release approval remain outstanding; no merge/release authorized. + +- **T04-H68 read-only recovery evidence preservation (31 October 2026; NOT ACCEPTED):** Exact PR #92 base SHA `7900495825a16107b330d3ead3358f7f55628701` could mutate `pending-commit.json`, missing derived snapshot and unwitnessed torn ledger suffix during `loadStateIndex({ rebuildIfNeeded: false })`. Test-only draft PR #154 SHA `2c2f9206b7d7ea0ff8eeff2d7720e5c65a89f228` reproduced both regressions on Ubuntu and Windows in RED CI `41234074315`. Narrow correction in draft PR #153 SHA `4d48e64a0594a587ad61fd3693ae065db30fadfb` makes explicit no-rebuild canonical reads fail closed on pending recovery or unterminated ledger tails, preserving crash evidence, while retaining the H17 explicit integrity-recovery contract. Complete Ubuntu and Windows validation PASS in GREEN CI `41234104614`; source blob `65c2681eac2b7a890c9825beba0b76f9de0b13df`, test blob `c266d928ba1514a8d5018ce2ef19798f2259d1ae`. Integrated into PR #92 only after this cross-platform proof. H68 legacy migration/cutover P1, Issue #73 controlled physical NTFS power-loss evidence, three distinct authenticated exact-source reviews and persisted risk-3 ACCEPTED remain outstanding. No merge or release authorized. + +- **T04-H68 legacy migration/cutover writer serialization (31 October 2026; NOT ACCEPTED):** Genuine PR #92 P1 at source `7900495825a16107b330d3ead3358f7f55628701` demonstrated an acknowledged legacy writer after migration inspection could be omitted from the backup/V2 while MIGRATION_COMPLETED falsely reported equivalence. Test-only diagnostic PR #152 SHA `1c887b6fac7657b69d98491d4aee833582eb162e` reproduced the failure in RED CI `41233222609`. Dedicated fail-closed cross-process gate now spans inspection, backup, V2 import and completion, and the supported Autopilot/orchestration state writers acquire it before selecting legacy or V2 authority. New tests verify interleaved writer rejection/retry, independent-process exclusion and orphaned-gate evidence preservation. Exact diagnostic source SHA `b589d9cd2aaf09cf3226fe2ba967a7dd492c32ed` passed complete Ubuntu and Windows validation in GREEN CI `41240549095`. An orphaned gate is never automatically reclaimed; offline operator recovery requires stopping all participating writers, preserving gate/legacy/V2 evidence and proving exclusive ownership before manual removal. This cooperative gate does not claim protection against arbitrary same-UID attackers (Issue #72). Issue #73 physical NTFS power-loss evidence, independent exact-source role approvals, two complete final integration-head matrices and persisted risk-3 ACCEPTED remain outstanding. No merge/release authorization. + +- **T04-H69/H70 legacy pointer recovery and I/O fail-closed hardening (31 October 2026; NOT ACCEPTED):** Independent PR #92 P2 `#discussion_r4433270346` showed explicit and automatic legacy pointer recovery could mutate legacy authority during migration without the H68 cross-process gate. Test-only diagnostic PR #156 SHA `429fe1d24b07ab7aaf79c1a1f787122e5832d789` reproduced both bypasses in RED CI `41241772321`; narrowly scoped source SHA `5d14a709b45537e75f26308ab50a27638edca291` passed complete Windows and Ubuntu validation (Ubuntu UI-preview health-check flake passed on retry) in CI `41241884533`. Further source audit identified H70: transient ENOSPC publishing the newest valid legacy pointer or EIO reading the newest acknowledged revision could be swallowed and silently roll back to an older revision. Diagnostic PR #157 test-only SHA `f6da53258cb3762efddefe4194a80858dc1dae5e` reproduced both defects on Ubuntu and Windows in RED CI `41242533566`. Corrected diagnostic SHA `df52faf7e18fcbc0065bcd8bcd7e793a89f943fe` passed **both complete Ubuntu and Windows** validation jobs in GREEN CI `41242573501`; only invalid revision content is eligible for fallback, while read/write I/O faults propagate fail-closed. Ported only byte-identical validated source blob `8c6662d597accb152b7be30c521b09a6233a12e9` and test blob `be14055c64e0388de56709c7e2c63543dd2ea255` plus this memory evidence to authoritative v0.13 PR #92. Fresh exact-head integration CI, two independent full final-head Ubuntu/Windows executions, exact-source independent code/architecture/security approvals, controlled physical NTFS sudden-power-loss evidence under Issue #73 and persisted risk-3 ACCEPTED remain mandatory. No merge or release authorized. + +- **T04-H71 legacy pointer/revision read-I/O fail-closed correction (31 October 2026; NOT ACCEPTED):** `getLegacyCurrentState()` previously caught EIO while reading acknowledged `current.json` or its current revision as if it were malformed JSON, then entered recovery and rewrote the pointer. Test-only draft diagnostic PR #159 SHA `e7dc2570264c43eb5cb98b562717345634efdaac` reproduced both failures on Ubuntu and Windows in RED CI `41245623327` (`not ok 126/127`, missing expected I/O exception). Narrow correction diagnostic SHA `8dfba42f40103635da3725f075da6d1593bc92bd` separates filesystem read errors from malformed pointer/revision content: ENOENT retains the existing absent/missing-revision recovery semantics, EIO/EACCES propagate without mutation, and only canonical `revision-NNNNNN.json` pointer targets are accepted. Both complete Ubuntu and Windows validations passed in GREEN CI `41245703767`, including both H71 tests and v0.13 compatibility. Ported byte-identical runtime source blob `a38e85bd8c0fbb008f8ef28c4f7e9f05dc6de0bb` and test blob `8e5fc76b42079790a5209c2f9da7186eec0c176d` into authoritative draft PR #92. Prior H69 review thread `#discussion_r4433270346` was resolved with source-bound RED/GREEN evidence, not counted as an independent role approval. Fresh final integration-head two-run Ubuntu/Windows validation, three distinct authenticated exact-source code/architecture/security reviews, physical NTFS sudden-power-loss assurance (Issue #73) and persisted deterministic risk-3 ACCEPTED remain mandatory. No merge or release authorization. + +- **T04-H72 legacy symlink/backup completeness (1 November 2026; NOT ACCEPTED):** Independent exact-source PR #92 review at `4f50559ce8cf8c125212c7cea3999bae02859352` found that `collectLegacyBackupFiles()` silently omitted symlinked Autopilot revision/pointer inputs while `autopilotHistory()` followed and imported them, allowing false semantic equivalence, incomplete backup and incomplete source fingerprint. Test-only diagnostic draft PR #162 commit `52b9b3b6f8fb38cbfa70d67a34e2e58c3836fd9d` reproduced both defects on **Ubuntu and Windows** in RED CI `41247622308` (both full validation jobs failed on the expected H72 negative controls). Narrow fail-closed fix rejects symlinks and junctions in scanned legacy source trees and their ancestor paths before backup/fingerprint/cutover. Three H72 regressions (symlinked revision, symlinked pointer and junctioned state root) all passed without skips on both platforms in full GREEN CI `41247716349` on diagnostic source `64ae5a15f82ed58805da79ae0887ceff9e93bfcc`; earlier GREEN `41247698757` also passed both full platforms for the first two tests. Ported only validated runtime blob `d6c5d93f117146db5b24c5372562c0c04c767329` and test blob `174ea49179691686a1d72be9cae463ccbcf728a1` byte-identically into authoritative v0.13 draft PR #92 (runtime commit `2f08f1f7cd685c9043f9110f72a3e5cfbdea431a`, tests commit `08c2be5a3cd56f2cdbdb1ef54e863b67118389be`). This prevents false backup certification for symlinked source inputs; it does not prove hostile same-UID TOCTOU safety, NTFS sudden physical power-loss durability, three independent exact-final-source role approvals or persisted deterministic risk-3 ACCEPTED. Fresh final integration-head dual Ubuntu/Windows validation is required; no merge/release authority. + +- **T04-H73 orphaned legacy witness fail-closed correction (1 November 2026; NOT ACCEPTED):** Exact v0.13 PR #92 source `e9ba6e3bfcf8d859401f5501e3a1d77178dbcd34` silently treated a surviving Autopilot `current.json` without revision files and orchestration `state-revisions`/pointer without immutable `manifest.json` as empty histories. This could certify an incomplete migration and publish `MIGRATION_COMPLETED` while legacy evidence remained. Diagnostic draft PR #164 committed two adversarial tests first at `5e6f57d1b3b34cb32182987a694eeff9caf1c2a1`; both reproduced as expected failures on Ubuntu and Windows in RED CI `41251264165`. Narrow source fix rejects both incomplete legacy witness classes before canonical layout/backup/cutover; the original legacy files remain intact. Complete Ubuntu and Windows validation passed in GREEN CI `41251304713` on diagnostic commit `6d3228d7c25753b1747cebe5d6ad3220c6b22731`. Runtime source blob `7f46e190a1dcb4dfab8c8c6d81c663edca33bb69` and test blob `7cf5b15d9b6259096992c422bd0f2740c4442c2d` are the exact validated artifacts. Three independent authenticated exact-final-source role approvals, two complete final-head platform runs, physical NTFS sudden-power-loss assurance under Issue #73 and persisted deterministic risk-3 ACCEPTED remain mandatory. No merge or release authority. + +- **T04-H74 orphaned orchestration pointer fail-closed correction (1 November 2026; NOT ACCEPTED):** Actual v0.13 PR #92 source at `bd7bebad9368b81674789cb707dae72bdc388863` accepted a legacy orchestration `current-state.json` pointer to absent revision files when `manifest.json` survived and `final-state.json` was absent. `inspectLegacyState()` silently treated this as a manifest-only revision-1 history, allowing incomplete migration certification. Diagnostic draft PR #166 test-only commit `7dbf790d951d08dcbe7037a1ee9fc8b9afaa6ba4` reproduced the missing fail-closed exception on **both Ubuntu and Windows** in RED CI [#41258831305](https://github.com/eybersjp/development-kit/actions/runs/41258831305). Narrow correction `da1ccaf51b9c47da6ce8624b893ba67fe5b679c7` rejects a surviving pointer without its immutable revision history before canonical layout/backup/cutover; it preserves the original pointer and manifest. Complete Ubuntu and Windows validation passed in GREEN CI [#41258894217](https://github.com/eybersjp/development-kit/actions/runs/41258894217), including H74 and v0.13 compatibility. Ported exact validated source blob `f7c72a1efc867a9af25bfbf3bde70399ee2bb5b4` and regression test blob `a2add156ace088ed9f939ff404b9d3f83d95e817` byte-identically to authoritative PR #92 via commits `791c70b1dc6291f0a11cd4a4071b8b9287f216be` and `33941032894538b3f47a113b5b0655a2872a4939`. Final integration-head two independent full Ubuntu/Windows runs, three distinct authenticated exact-source code/architecture/security approvals, controlled NTFS physical sudden-power-loss evidence (Issue #73) and persisted deterministic risk-3 ACCEPTED remain mandatory. No merge, release or waiver authorized. + +- **T04-H75 read-only V2 Control Center projection (1 November 2026; NOT ACCEPTED):** Exact authoritative v0.13 PR #92 source `e4f254cf991a65224cf65e7516d3b446a0e9b01a` could replay a valid pending V2 commit and retire `pending-commit.json` during the ostensibly read-only `GET /v1/scale-context`. A new adversarial V13-API-PROJECTION-04 fixture creates a valid pending revision-2 suffix over acknowledged revision-1 events/snapshot/index and asserts that the GET reports degraded/recovery-required without changing any of the four byte-level witnesses. Test-first diagnostic PR #168 RED commit `6f69e3c76c17256eb6ff5019a5208746e8857fc0` failed on **Ubuntu and Windows** in full CI [#41263121147](https://github.com/eybersjp/development-kit/actions/runs/41263121147) on the exact expected negative control; both platform logs identified V13-API-PROJECTION-04 as the sole failing test. Narrow diagnostic PR #169 correction commit `3ff81aa3cb1ef5b09d59ed8242e86c859a5f5f3e` uses `loadStateSnapshot({ rebuildIfNeeded: false })` for both V2 authority selection and workflow projection; it neither replays pending canonical events nor rewrites snapshot/index/journal, and retains the unmigrated v0.12.1 legacy pointer projection. Full Ubuntu and Windows validation passed in GREEN CI [#41263092726](https://github.com/eybersjp/development-kit/actions/runs/41263092726). Ported exact validated API source blob `c3824931c8d8e1ccdb3727b2eb044ab6d47cf8dd` and test blob `f9a86424137ec237eb97e3b75d5e59721638f424` into authoritative PR #92 via commits `c3fb186b8f117d76e259b102b503d46b11605055` and `5bf9ecedd8686096bd7ad6dce9eeffb2c53bffdd`. H75 is a corrected diagnostic finding, not an independent role approval. Two full Ubuntu/Windows validations on the final memory-updated PR #92 SHA, genuine three-role independent source-bound approvals, Issue #73 controlled physical NTFS sudden-power-loss evidence and persisted deterministic risk-3 ACCEPTED remain mandatory. No merge or release authorization. + +- **T04-H76/H77 read-only canonical witness hardening (1 November 2026; NOT ACCEPTED):** Source-bound independent Codex review of authoritative PR #92 at `b77ad0a471eec0c2f7f06a2f254c7357fcb64769` identified two P2 faults. H76: `GET /v1/scale-context` could mutate interrupted bootstrap schema hard-link evidence during `isStateEngineV2Active` preflight. H77: a later acknowledged `index.db` witness could be ignored when both `events.jsonl` and `snapshot.json` regressed, allowing stale workflow projection. Draft diagnostic [PR #173](https://github.com/eybersjp/development-kit/pull/173) committed V13-API-PROJECTION-05/06 tests first at `322a4c6ae6d8050682b8fdbeeed63efd21dce308`; both Ubuntu and Windows RED CI [#41264569817](https://github.com/eybersjp/development-kit/actions/runs/41264569817) reproduced both expected failures. Narrow correction `3792c91128cc8b34da356980683968e3059b67fd` separates read-only bootstrap preflight from recovery-capable writes, rejects surviving index sequence/hash regression before returning a snapshot, and retries only a non-mutating canonical read to tolerate concurrent publication. Complete Ubuntu and Windows GREEN CI [#41264602042](https://github.com/eybersjp/development-kit/actions/runs/41264602042) passed, including both new adversarial fixtures and full v0.13 compatibility. Exact validated blobs: tests `e8644e8992350ae1abfac44c84432b9311ccc05b`, State Engine `348c593186f3e40d66fddfde49e17bbcd363a8a6`, runtime API `b5aaac4352e36c1c2649d204ab36aaec56f7926f`. All three ported byte-identically to authoritative PR #92 via commits `80152156ccda8617ef102ded0170c385b50d8c9a`, `5966101ac7c43f3d2f5b94f3554d6138ec547a92`, `51388a47b08f446d2a4ba9163d1c5ff04352a674`. Two full Ubuntu/Windows runs on the final integration SHA, independent exact-source code/architecture/security approvals, physical NTFS sudden-power-loss evidence (Issue #73) and persisted risk-3 ACCEPTED remain mandatory; no merge or release authorized. + +- **T04-H78 NTFS restore-path preflight (1 November 2026; NOT ACCEPTED):** Draft diagnostic PR #175 added re-fingerprinted legacy backup path regressions for NTFS alternate-data-stream colon syntax, DOS reserved `CON`/`AUX.txt`/`COM¹.txt` names and wildcard `?`, requiring rejection before any stage/journal/root mutation on both platforms. Test-only SHA `1e48500f091000353908dc266ff8bb8d9f8d599b` reproduced H78 on Ubuntu (missing exception) and Windows (`EINVAL` after reaching staging) in full RED CI `41269537745`. Narrow fix diagnostic SHA `ca5c4bab8fff68d4e11fb5ca6028c5f0f631fe8d` validates every backup path component against Win32 forbidden characters, control codes and DOS device names before staging, irrespective of validation host. Both complete Ubuntu and Windows jobs PASS in GREEN CI `41269595080`. Exact validated source blob `a6cbe5c265101f6db5fb2f08e3e2064d72746845` and test blob `eb7431c23e5972796eef76ad67622776bed9d185` are ported without modification. This is path-security/portability evidence, **not** physical NTFS power-loss durability, independent role approval, risk-3 ACCEPTED, merge or release authorization. Issue #73 and three-role reviews remain blocking. diff --git a/package.json b/package.json index e07a7f081..aa4b481a5 100644 --- a/package.json +++ b/package.json @@ -95,7 +95,10 @@ "complexity-delta:test": "node --test scripts/complexity-delta.test.mjs", "control-center-v012:test": "node --test scripts/control-center-v012.test.mjs", "release-scope:v012": "node --test scripts/v012-release-scope.test.mjs", - "consumer-smoke:v012": "node scripts/v012-consumer-smoke.mjs" + "consumer-smoke:v012": "node scripts/v012-consumer-smoke.mjs", + "v013-state-engine-v2:test": "node scripts/v013-state-engine-v2-contract.mjs && node --test scripts/state-engine-v2.test.mjs scripts/state-engine-v2-migration.test.mjs scripts/state-engine-v2-hardening.test.mjs scripts/state-engine-v013.test.mjs scripts/t04-review-thread-blockers.test.mjs", + "state-engine-v2:benchmark": "node scripts/state-engine-v2-benchmark.mjs --events 300", + "v013:regression": "npm run validate && npm run skills:validate:test && npm run doctor && npm run sync:validate:test && npm run docs:validate && npm run docs:validate:test && npm run opencode:validate && npm run platform:validate && npm run research:validate && npm run next-step:test && npm run installer:validate:test && npm run git-bootstrap:test && npm run orchestration:validate && npm run execution-safety:validate && npm run evidence:validate && npm run orchestration-core:validate && npm run orchestration-integration:validate && npm run v09-reliability:validate && npm run intelligence:validate && npm run v071:validate && npm run ui-preview:test && npm run token-efficiency:test && npm run token:audit:check && npm run cost-observatory:test && npm run workspace-targets:test && npm run execution-capsule:test && npm run design-authority:validate && npm run autopilot:validate && npm run numbered-decisions:test && npm run configuration-readiness:validate && npm run development-modes:validate && npm run idea-authority:validate && npm run lifecycle-instances:test && npm run discovery-reentry:test && npm run gate-profiles:test && npm run verification-extension-sdk:test && npm run complexity-delta:test && npm run control-center-v012:test && npm run v013-state-engine-v2:test" }, "engines": { "node": ">=18.0.0" diff --git a/runtime/api/runtime-api-service.mjs b/runtime/api/runtime-api-service.mjs index 7e3bf5cd8..b5aaac435 100644 --- a/runtime/api/runtime-api-service.mjs +++ b/runtime/api/runtime-api-service.mjs @@ -22,6 +22,7 @@ import { resolveMemoryIdentity } from '../intelligence/memory-identity.mjs'; import { resolveEffectiveSettings } from '../intelligence/settings.mjs'; import { LocalMemoryProvider } from '../intelligence/local-memory-provider.mjs'; import { getCurrentState } from '../autopilot/state-store.mjs'; +import { isStateEngineV2Active, loadStateSnapshot } from '../orchestration/state-engine-v2.mjs'; import { validateWorkflowState } from '../autopilot/validators.mjs'; import { loadWorkspaceTargetRegistry } from '../orchestration/workspace-targets.mjs'; import { loadCostRecords } from '../orchestration/cost-observatory.mjs'; @@ -273,38 +274,96 @@ export class RuntimeApiService { } if (method === 'GET' && pathname === '/v1/scale-context') { - // Read-only projection over v0.11's supported state authority. T04's - // canonical event ledger is deliberately NOT included in v0.12. + // Select the project's actual persistence authority. Released v0.12.1 + // projects retain the legacy pointer projection; fresh/migrated v0.13 + // projects must never read a stale pointer or fabricate one for the UI. const directory = path.join(this.rootDir, '.development-kit', 'autopilot', 'state'); const pointerPath = path.join(directory, 'current.json'); let stateHealth = 'unavailable'; let state = { active: false, currentStage: null, revision: null }; let activeContractId = null; - if (fs.existsSync(pointerPath)) { + let v2Active = false; + let v2Invalid = false; + try { + // Read-only Control Center preflight must never clean up a terminated + // bootstrap staging hard link or consume its crash-recovery evidence. + v2Active = isStateEngineV2Active(this.rootDir, { recoverIfNeeded: false }); + } catch { + v2Invalid = true; + } + // V2 layout existence alone does not migrate an existing legacy workflow. + // Selecting legacy authority must use the non-mutating pointer projection + // below: getCurrentState() can repair current.json as a side effect. + let v2WorkflowAuthority = v2Active; + let v2Snapshot = null; + if (v2Active) { try { - const pointer = JSON.parse(fs.readFileSync(pointerPath, 'utf8')); - if (!Number.isSafeInteger(pointer.currentRevision) || - !/^revision-\d{6}\.json$/.test(pointer.currentRevisionFile) || - Number(pointer.currentRevisionFile.slice(9, 15)) !== pointer.currentRevision) { - throw new Error('Invalid revision pointer'); + // An observing GET must not replay a pending commit, rebuild derived + // state, or retire crash evidence while selecting V2 authority. + v2Snapshot = loadStateSnapshot(this.rootDir, { rebuildIfNeeded: false }); + if (!v2Snapshot) throw new Error('V2 snapshot is unavailable for read-only projection'); + const hasLegacyRevisions = fs.existsSync(directory) && + fs.readdirSync(directory).some((name) => /^revision-\d{6}\.json$/.test(name)); + if (hasLegacyRevisions) { + const migrated = v2Snapshot.migrations.some((item) => item.semanticEquivalence === true); + const workflowRecords = v2Snapshot.entities['autopilot-workflow'] ?? {}; + v2WorkflowAuthority = migrated && Object.keys(workflowRecords).length > 0; } - const revisionPath = path.join(directory, pointer.currentRevisionFile); - if (!fs.existsSync(revisionPath)) { - stateHealth = 'recovery-required'; - } else { - const revision = JSON.parse(fs.readFileSync(revisionPath, 'utf8')); + } catch { + // An interrupted or unreadable V2 authority cannot authorize legacy + // fallback, and read-only GET must leave all evidence untouched. + v2Invalid = true; + v2WorkflowAuthority = false; + } + } + if (v2WorkflowAuthority) { + try { + const workflowRecords = v2Snapshot.entities['autopilot-workflow'] ?? {}; + const revision = Object.entries(workflowRecords) + .sort((a, b) => b[1].lastEventSequence - a[1].lastEventSequence || + a[0].localeCompare(b[0]))[0]?.[1]?.state ?? null; + if (revision) { validateWorkflowState(revision); - if (revision.stateRevision !== pointer.currentRevision) { - throw new Error('Pointer revision mismatch'); - } stateHealth = 'unverified'; - state = { active: true, currentStage: revision.currentStage, revision: revision.stateRevision }; + state = { active: true, currentStage: revision.currentStage, + revision: revision.stateRevision }; activeContractId = typeof revision.orchestration?.activeContractId === 'string' ? revision.orchestration.activeContractId : null; } } catch { stateHealth = 'degraded'; } + } else if (v2Invalid) { + // Never silently fall back to a frozen legacy pointer when canonical + // V2 metadata is present but invalid or cannot be trusted. + stateHealth = 'degraded'; + } else { + if (fs.existsSync(pointerPath)) { + try { + const pointer = JSON.parse(fs.readFileSync(pointerPath, 'utf8')); + if (!Number.isSafeInteger(pointer.currentRevision) || + !/^revision-\d{6}\.json$/.test(pointer.currentRevisionFile) || + Number(pointer.currentRevisionFile.slice(9, 15)) !== pointer.currentRevision) { + throw new Error('Invalid revision pointer'); + } + const revisionPath = path.join(directory, pointer.currentRevisionFile); + if (!fs.existsSync(revisionPath)) { + stateHealth = 'recovery-required'; + } else { + const revision = JSON.parse(fs.readFileSync(revisionPath, 'utf8')); + validateWorkflowState(revision); + if (revision.stateRevision !== pointer.currentRevision) { + throw new Error('Pointer revision mismatch'); + } + stateHealth = 'unverified'; + state = { active: true, currentStage: revision.currentStage, revision: revision.stateRevision }; + activeContractId = typeof revision.orchestration?.activeContractId === 'string' + ? revision.orchestration.activeContractId : null; + } + } catch { + stateHealth = 'degraded'; + } + } } const modules = { workspaceTargets: 'workspace-targets.mjs', @@ -319,9 +378,11 @@ export class RuntimeApiService { id, { installed: fs.existsSync(new URL('../orchestration/' + filename, import.meta.url)), accepted: null, acceptanceStatus: 'not-independently-established' }, ])); - capabilities.stateEngineV2 = { - installed: false, accepted: false, acceptanceStatus: 'deferred-to-v0.13.0', - }; + capabilities.stateEngineV2 = v2Active + ? { installed: true, accepted: null, acceptanceStatus: 'not-independently-established' } + : v2Invalid + ? { installed: false, accepted: null, acceptanceStatus: 'degraded' } + : { installed: false, accepted: false, acceptanceStatus: 'deferred-to-v0.13.0' }; // Operational state is observed strictly from the existing stable // project stores. Never confuse an installed module with an active, // verified, or accepted capability, and never mutate data on GET. @@ -384,7 +445,8 @@ export class RuntimeApiService { }; return this._json(res, 200, { schemaVersion: '1.0.0', - canonicalStateStore: 'autopilot-versioned-snapshots', + canonicalStateStore: v2Active || v2Invalid + ? 'state-engine-v2-event-ledger' : 'autopilot-versioned-snapshots', stateHealth, state, capabilities, diff --git a/runtime/autopilot/state-store.mjs b/runtime/autopilot/state-store.mjs index 2c6e31e5f..a38e85bd8 100644 --- a/runtime/autopilot/state-store.mjs +++ b/runtime/autopilot/state-store.mjs @@ -1,14 +1,27 @@ /** - * Development Kit Autopilot — Immutable Snapshot State Store + * Development Kit Autopilot — State Store * - * Implements crash-consistent versioned snapshot persistence: - * .development-kit/autopilot/state/revision-XXXXXX.json & current.json. + * State Engine V2 is canonical for: + * - fresh projects with no legacy Autopilot state chain; + * - legacy projects after their workflow has been migrated into V2. + * + * Unmigrated legacy projects continue to use the legacy immutable-snapshot store + * until explicit migration succeeds. This prevents silent history loss. */ import fs from 'node:fs'; import path from 'node:path'; + import { validateWorkflowState } from './validators.mjs'; import { acquireTransactionLock, releaseTransactionLock } from './lock-manager.mjs'; +import { withMigrationGate } from '../orchestration/state-migration-gate.mjs'; +import { + appendEntityState, + hasCompletedStateMigration, + isStateEngineV2Active, + loadLatestEntityState, + rebuildStateSnapshot, +} from '../orchestration/state-engine-v2.mjs'; export class StateStoreError extends Error { constructor(message) { @@ -21,35 +34,81 @@ export function getStateDir(rootDir = process.cwd()) { return path.join(rootDir, '.development-kit', 'autopilot', 'state'); } -export function getCurrentState(rootDir = process.cwd()) { +function legacyStateExists(rootDir) { + const stateDir = getStateDir(rootDir); + if (!fs.existsSync(stateDir)) return false; + return fs.readdirSync(stateDir).some((name) => /^revision-\d{6}\.json$/.test(name)); +} + +function migratedWorkflowState(rootDir) { + if (!isStateEngineV2Active(rootDir)) return null; + return loadLatestEntityState('autopilot-workflow', rootDir); +} + +function shouldUseV2(rootDir) { + if (!legacyStateExists(rootDir)) return true; + return hasCompletedStateMigration(rootDir) && migratedWorkflowState(rootDir) !== null; +} + +function getLegacyCurrentState(rootDir) { const stateDir = getStateDir(rootDir); const currentFile = path.join(stateDir, 'current.json'); - if (!fs.existsSync(currentFile)) { - return null; + // Only an actually absent pointer may return null. Read failures such as + // EIO/EACCES must not be treated as malformed content and trigger a write. + let pointerContent; + try { + pointerContent = fs.readFileSync(currentFile, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; } + let pointer; try { - const pointer = JSON.parse(fs.readFileSync(currentFile, 'utf8')); - const revFile = path.join(stateDir, pointer.currentRevisionFile); - if (fs.existsSync(revFile)) { - const state = JSON.parse(fs.readFileSync(revFile, 'utf8')); + pointer = JSON.parse(pointerContent); + } catch { + // Syntactically invalid pointer content may be recovered under the gate. + return recoverLatestLegacyState(rootDir); + } + if (!pointer || typeof pointer.currentRevisionFile !== 'string' + || !/^revision-\d{6}\.json$/.test(pointer.currentRevisionFile)) { + return recoverLatestLegacyState(rootDir); + } + + const revFile = path.join(stateDir, pointer.currentRevisionFile); + let revisionContent; + try { + revisionContent = fs.readFileSync(revFile, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') return recoverLatestLegacyState(rootDir); + throw error; + } + try { + const state = JSON.parse(revisionContent); + validateWorkflowState(state); + return state; + } catch { + // Only malformed revision content/validation may select recovery. + return recoverLatestLegacyState(rootDir); + } +} + +export function getCurrentState(rootDir = process.cwd()) { + if (shouldUseV2(rootDir)) { + const state = migratedWorkflowState(rootDir); + if (state) { validateWorkflowState(state); return state; } - } catch { - // Pointer or state file corrupt — attempt recovery + if (!legacyStateExists(rootDir)) return null; } - - return recoverLatestValidState(rootDir); + return getLegacyCurrentState(rootDir); } -export function saveStateRevision(state, rootDir = process.cwd()) { - validateWorkflowState(state); +function saveLegacyStateRevision(state, rootDir) { const stateDir = getStateDir(rootDir); - if (!fs.existsSync(stateDir)) { - fs.mkdirSync(stateDir, { recursive: true }); - } + if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true }); const lock = acquireTransactionLock(rootDir); try { @@ -63,7 +122,6 @@ export function saveStateRevision(state, rootDir = process.cwd()) { fs.writeSync(fd, content, 'utf8'); fs.fsyncSync(fd); fs.closeSync(fd); - fs.renameSync(tmpFilePath, revFilePath); const currentPointerPath = path.join(stateDir, 'current.json'); @@ -72,14 +130,13 @@ export function saveStateRevision(state, rootDir = process.cwd()) { currentRevision: state.stateRevision, currentRevisionFile: revFileName, workflowId: state.workflowId, - updatedAt: new Date().toISOString() + updatedAt: new Date().toISOString(), }, null, 2); const pFd = fs.openSync(tmpPointerPath, 'w'); fs.writeSync(pFd, pointerContent, 'utf8'); fs.fsyncSync(pFd); fs.closeSync(pFd); - fs.renameSync(tmpPointerPath, currentPointerPath); return state; } finally { @@ -87,36 +144,92 @@ export function saveStateRevision(state, rootDir = process.cwd()) { } } -export function recoverLatestValidState(rootDir = process.cwd()) { +function saveStateRevisionUnlocked(state, rootDir = process.cwd()) { + validateWorkflowState(state); + + if (!shouldUseV2(rootDir)) { + return saveLegacyStateRevision(state, rootDir); + } + + appendEntityState({ + rootDir, + entityType: 'autopilot-workflow', + entityId: state.workflowId, + state, + actorClass: 'autopilot', + timestamp: state.updatedAt, + }); + return state; +} + +export function saveStateRevision(state, rootDir = process.cwd()) { + return withMigrationGate(rootDir, () => saveStateRevisionUnlocked(state, rootDir)); +} + +function recoverLatestLegacyStateUnlocked(rootDir) { const stateDir = getStateDir(rootDir); if (!fs.existsSync(stateDir)) return null; const files = fs.readdirSync(stateDir) - .filter(f => /^revision-\d{6}\.json$/.test(f)) + .filter((file) => /^revision-\d{6}\.json$/.test(file)) .sort() .reverse(); for (const file of files) { + // Only malformed revision content is eligible for fallback. An I/O error + // while reading the newest revision is not evidence that it is invalid: + // silently choosing an older one would roll back acknowledged history. + const filePath = path.join(stateDir, file); + const content = fs.readFileSync(filePath, 'utf8'); + let state; try { - const filePath = path.join(stateDir, file); - const state = JSON.parse(fs.readFileSync(filePath, 'utf8')); + state = JSON.parse(content); validateWorkflowState(state); - - // Repair pointer - const pointerContent = JSON.stringify({ - currentRevision: state.stateRevision, - currentRevisionFile: file, - workflowId: state.workflowId, - updatedAt: new Date().toISOString(), - recoveredAt: new Date().toISOString() - }, null, 2); - - fs.writeFileSync(path.join(stateDir, 'current.json'), pointerContent, 'utf8'); - return state; } catch { - // Try previous revision + // Corrupt revision content may be skipped in favor of an older valid one. + continue; } + + const pointerContent = JSON.stringify({ + currentRevision: state.stateRevision, + currentRevisionFile: file, + workflowId: state.workflowId, + updatedAt: new Date().toISOString(), + recoveredAt: new Date().toISOString(), + }, null, 2); + + // Publication errors must propagate; never retry with an older revision + // after the latest valid revision has already been selected. + fs.writeFileSync(path.join(stateDir, 'current.json'), pointerContent, 'utf8'); + return state; } return null; } + +// Pointer repair is a legacy write. Reacquire the migration gate and recheck +// authority *inside* it so a stale reader cannot repair legacy after cutover. +function recoverLatestLegacyState(rootDir) { + return withMigrationGate(rootDir, () => { + if (shouldUseV2(rootDir) && isStateEngineV2Active(rootDir)) { + const current = migratedWorkflowState(rootDir); + if (current) return current; + } + return recoverLatestLegacyStateUnlocked(rootDir); + }); +} + +export function recoverLatestValidState(rootDir = process.cwd()) { + return withMigrationGate(rootDir, () => { + if (shouldUseV2(rootDir) && isStateEngineV2Active(rootDir)) { + const current = migratedWorkflowState(rootDir); + if (current) { + rebuildStateSnapshot(rootDir); + const rebuilt = loadLatestEntityState('autopilot-workflow', rootDir); + validateWorkflowState(rebuilt); + return rebuilt; + } + } + return recoverLatestLegacyStateUnlocked(rootDir); + }); +} diff --git a/runtime/orchestration/index.mjs b/runtime/orchestration/index.mjs index 180472827..c6434dbc4 100644 --- a/runtime/orchestration/index.mjs +++ b/runtime/orchestration/index.mjs @@ -158,3 +158,6 @@ export * from './discovery-reentry.mjs'; export * from './gate-profiles.mjs'; export * from './verification-extension-sdk.mjs'; export * from './complexity-delta.mjs'; + +export * from './state-engine-v2.mjs'; +export * from './state-engine-migration.mjs'; diff --git a/runtime/orchestration/orchestration-run.mjs b/runtime/orchestration/orchestration-run.mjs index e7ce9d7f7..c71007b07 100644 --- a/runtime/orchestration/orchestration-run.mjs +++ b/runtime/orchestration/orchestration-run.mjs @@ -5,6 +5,14 @@ import { validateDevelopmentContract } from './development-contract.mjs'; import { resolveContractGateProfiles } from './gate-profiles.mjs'; import { selectExecutionStrategy } from './host-capabilities.mjs'; import { getRunDirectory } from './evidence-store.mjs'; +import { withMigrationGate } from './state-migration-gate.mjs'; +import { + appendEntityState, + canonicalStateJson, + hasCompletedStateMigration, + isStateEngineV2Active, + loadEntityState, +} from './state-engine-v2.mjs'; const RUN_STATES = Object.freeze([ 'READY', @@ -56,6 +64,28 @@ function persistImmutable(filePath, value, label) { return { created: true, path: filePath }; } +function runEntityId(contractId, runId) { + return `${contractId}/${runId}`; +} + +function legacyRunStateExists(contractId, runId, rootDir) { + const runDir = getRunDirectory(rootDir, contractId, runId); + const revisionDir = path.join(runDir, 'state-revisions'); + if (fs.existsSync(path.join(runDir, 'current-state.json'))) return true; + if (!fs.existsSync(revisionDir)) return false; + return fs.readdirSync(revisionDir).some((name) => /^\d{8}\.json$/.test(name)); +} + +function v2RunState(contractId, runId, rootDir) { + if (!isStateEngineV2Active(rootDir)) return null; + return loadEntityState('orchestration-run', runEntityId(contractId, runId), rootDir); +} + +function shouldUseV2RunState(contractId, runId, rootDir) { + if (!legacyRunStateExists(contractId, runId, rootDir)) return true; + return hasCompletedStateMigration(rootDir) && v2RunState(contractId, runId, rootDir) !== null; +} + export function createOrchestrationRun({ contract, runId, @@ -145,37 +175,84 @@ export function validateOrchestrationRun(run) { return true; } -export function persistRunManifest(run, rootDir = process.cwd()) { +function persistRunManifestUnlocked(run, rootDir = process.cwd()) { validateOrchestrationRun(run); if (run.stateRevision !== 1) throw new OrchestrationRunError('Initial run manifest may only persist stateRevision 1'); const runDir = getRunDirectory(rootDir, run.contractId, run.runId); return persistImmutable(path.join(runDir, 'manifest.json'), run, 'orchestration run manifest'); } -export function persistRunStateRevision(run, rootDir = process.cwd()) { +function persistRunStateRevisionUnlocked(run, rootDir = process.cwd()) { validateOrchestrationRun(run); - const runDir = getRunDirectory(rootDir, run.contractId, run.runId); - const revisionName = `${String(run.stateRevision).padStart(8, '0')}.json`; - const revisionPath = path.join(runDir, 'state-revisions', revisionName); - const persisted = persistImmutable(revisionPath, run, 'orchestration run state revision'); - const pointer = { - schemaVersion: '1.0.0', - contractId: run.contractId, - runId: run.runId, - stateRevision: run.stateRevision, - revisionPath: `state-revisions/${revisionName}`, + + if (!shouldUseV2RunState(run.contractId, run.runId, rootDir)) { + const runDir = getRunDirectory(rootDir, run.contractId, run.runId); + const revisionName = `${String(run.stateRevision).padStart(8, '0')}.json`; + const revisionPath = path.join(runDir, 'state-revisions', revisionName); + const persisted = persistImmutable(revisionPath, run, 'orchestration run state revision'); + const pointer = { + schemaVersion: '1.0.0', + contractId: run.contractId, + runId: run.runId, + stateRevision: run.stateRevision, + revisionPath: `state-revisions/${revisionName}`, + }; + atomicWrite(path.join(runDir, 'current-state.json'), stableContent(pointer)); + return { ...persisted, pointerPath: path.join(runDir, 'current-state.json'), stateEngine: 'legacy' }; + } + + const existing = v2RunState(run.contractId, run.runId, rootDir); + if (existing) { + if (run.stateRevision < existing.stateRevision) { + throw new OrchestrationRunError('Refusing orchestration run stateRevision regression'); + } + if (run.stateRevision === existing.stateRevision) { + if (canonicalStateJson(run) === canonicalStateJson(existing)) { + return { created: false, stateEngine: 'v2', path: null, pointerPath: null }; + } + throw new OrchestrationRunError('Refusing to overwrite orchestration run state revision'); + } + } + + const result = appendEntityState({ + rootDir, + entityType: 'orchestration-run', + entityId: runEntityId(run.contractId, run.runId), + state: run, + refs: { + contractId: run.contractId, + taskId: run.taskId, + runId: run.runId, + }, + actorClass: 'orchestration', + timestamp: run.updatedAt, + }); + + return { + created: result.changed, + stateEngine: 'v2', + path: null, + pointerPath: null, + eventId: result.event?.eventId ?? null, }; - atomicWrite(path.join(runDir, 'current-state.json'), stableContent(pointer)); - return { ...persisted, pointerPath: path.join(runDir, 'current-state.json') }; } -export function persistFinalRunState(run, rootDir = process.cwd()) { +function persistFinalRunStateUnlocked(run, rootDir = process.cwd()) { validateOrchestrationRun(run); if (!['ACCEPTED', 'BLOCKED'].includes(run.state)) throw new OrchestrationRunError('Final run state requires ACCEPTED or BLOCKED'); const runDir = getRunDirectory(rootDir, run.contractId, run.runId); return persistImmutable(path.join(runDir, 'final-state.json'), run, 'final run state'); } +export function persistRunManifest(run, rootDir = process.cwd()) { + return withMigrationGate(rootDir, () => persistRunManifestUnlocked(run, rootDir)); +} +export function persistRunStateRevision(run, rootDir = process.cwd()) { + return withMigrationGate(rootDir, () => persistRunStateRevisionUnlocked(run, rootDir)); +} +export function persistFinalRunState(run, rootDir = process.cwd()) { + return withMigrationGate(rootDir, () => persistFinalRunStateUnlocked(run, rootDir)); +} export function loadRunManifest(contractId, runId, rootDir = process.cwd()) { const filePath = path.join(getRunDirectory(rootDir, contractId, runId), 'manifest.json'); if (!fs.existsSync(filePath)) return null; @@ -185,6 +262,16 @@ export function loadRunManifest(contractId, runId, rootDir = process.cwd()) { } export function loadCurrentRunState(contractId, runId, rootDir = process.cwd()) { + if (shouldUseV2RunState(contractId, runId, rootDir)) { + const v2 = v2RunState(contractId, runId, rootDir); + if (v2) { + validateOrchestrationRun(v2); + if (v2.contractId !== contractId || v2.runId !== runId) { + throw new OrchestrationRunError('State Engine V2 run identity mismatch'); + } + return v2; + } + } const runDir = getRunDirectory(rootDir, contractId, runId); const pointerPath = path.join(runDir, 'current-state.json'); if (!fs.existsSync(pointerPath)) return loadRunManifest(contractId, runId, rootDir); diff --git a/runtime/orchestration/state-engine-migration.mjs b/runtime/orchestration/state-engine-migration.mjs new file mode 100644 index 000000000..b54e66f05 --- /dev/null +++ b/runtime/orchestration/state-engine-migration.mjs @@ -0,0 +1,1951 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +import { validateWorkflowState } from '../autopilot/validators.mjs'; +import { withMigrationGate } from './state-migration-gate.mjs'; +import { validateOrchestrationRun } from './orchestration-run.mjs'; +import { + appendEntityState, + appendMetadataEvent, + canonicalStateJson, + ensureStateEngineLayout, + getStateEnginePaths, + loadCanonicalEvents, + loadEntityState, + loadStateSnapshot, + stateSha256, + verifyStateEngineIntegrity, +} from './state-engine-v2.mjs'; + +export const LEGACY_BACKUP_SCHEMA_VERSION = '1.0.0'; +export const LEGACY_MIGRATION_SCHEMA_VERSION = '1.0.0'; +export const LEGACY_BACKUP_FILE = 'legacy-backup.json'; + +export class StateMigrationError extends Error { + constructor(message, details = null) { + super(message); + this.name = 'StateMigrationError'; + this.details = details; + } +} + +function plainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])); +} + +function stablePretty(value) { + return `${JSON.stringify(stable(value), null, 2)}\n`; +} + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function readJson(filePath, label) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf8')); + } catch (error) { + throw new StateMigrationError(`${label} is invalid JSON: ${error.message}`); + } +} + +function projectRelative(rootDir, absolutePath) { + const root = path.resolve(rootDir); + const absolute = path.resolve(absolutePath); + const relative = path.relative(root, absolute).replaceAll('\\', '/'); + if (relative === '..' || relative.startsWith('../') || path.isAbsolute(relative)) { + throw new StateMigrationError('Legacy state path escapes project root'); + } + return relative; +} + +function resolveProjectPath(rootDir, relativePath) { + const root = path.resolve(rootDir); + const absolute = path.resolve(root, relativePath); + const relative = path.relative(root, absolute); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new StateMigrationError(`Backup path escapes project root: ${relativePath}`); + } + return absolute; +} + +function atomicWrite(filePath, content) { + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + const temp = `${filePath}.tmp-${process.pid}-${crypto.randomUUID()}`; + const fd = fs.openSync(temp, 'wx'); + try { + fs.writeFileSync(fd, content, 'utf8'); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(temp, filePath); +} + +function listFilesRecursive(directory, rootDir) { + // A legacy input consumed through a symlink cannot be silently omitted + // from the immutable backup manifest/fingerprint. Check every ancestor, + // including a junctioned state root, before inspecting its descendants. + let ancestor = path.resolve(rootDir); + for (const part of projectRelative(rootDir, directory).split('/')) { + ancestor = path.join(ancestor, part); + let metadata; + try { + metadata = fs.lstatSync(ancestor); + } catch (error) { + if (error.code === 'ENOENT') return []; + throw error; + } + if (metadata.isSymbolicLink()) { + throw new StateMigrationError( + 'Legacy backup source contains symbolic link: ' + projectRelative(rootDir, ancestor), + ); + } + } + + const files = []; + function walk(current) { + const entries = fs.readdirSync(current, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name)); + for (const entry of entries) { + const full = path.join(current, entry.name); + if (entry.isSymbolicLink()) { + throw new StateMigrationError( + 'Legacy backup source contains symbolic link: ' + projectRelative(rootDir, full), + ); + } + if (entry.isDirectory()) walk(full); + else if (entry.isFile()) files.push(full); + } + } + walk(directory); + return files; +} + +function collectLegacyBackupFiles(rootDir) { + const files = new Set(); + const autopilotState = path.join(rootDir, '.development-kit', 'autopilot', 'state'); + for (const file of listFilesRecursive(autopilotState, rootDir)) { + const name = path.basename(file); + if (name === 'current.json' || /^revision-\d{6}\.json$/.test(name)) files.add(file); + } + + const runsRoot = path.join(rootDir, '.development-kit', 'runs'); + for (const file of listFilesRecursive(runsRoot, rootDir)) { + const name = path.basename(file); + const parent = path.basename(path.dirname(file)); + if ( + name === 'manifest.json' + || name === 'current-state.json' + || name === 'final-state.json' + || (parent === 'state-revisions' && /^\d{8}\.json$/.test(name)) + ) files.add(file); + } + + return [...files].sort((a, b) => projectRelative(rootDir, a).localeCompare(projectRelative(rootDir, b))); +} + +function buildBackupBundle(rootDir, files) { + const entries = files.map((file) => { + const content = fs.readFileSync(file); + return { + path: projectRelative(rootDir, file), + fingerprint: stateSha256(content), + bytes: content.length, + contentBase64: content.toString('base64'), + }; + }); + const manifest = entries.map(({ path: entryPath, fingerprint, bytes }) => ({ path: entryPath, fingerprint, bytes })); + const sourceFingerprint = sha256(JSON.stringify(stable(manifest))); + return { + schemaVersion: LEGACY_BACKUP_SCHEMA_VERSION, + sourceFingerprint, + fileCount: entries.length, + files: entries, + }; +} + +function validateBackupBundle(bundle) { + if (!plainObject(bundle) || bundle.schemaVersion !== LEGACY_BACKUP_SCHEMA_VERSION) { + throw new StateMigrationError('Legacy backup bundle schema is invalid'); + } + if (typeof bundle.sourceFingerprint !== 'string' || !/^sha256:[a-f0-9]{64}$/.test(bundle.sourceFingerprint)) { + throw new StateMigrationError('Legacy backup sourceFingerprint is invalid'); + } + if (!Array.isArray(bundle.files) || bundle.fileCount !== bundle.files.length) { + throw new StateMigrationError('Legacy backup file list is invalid'); + } + const manifest = []; + for (const entry of bundle.files) { + if (!plainObject(entry) || typeof entry.path !== 'string' || typeof entry.contentBase64 !== 'string') { + throw new StateMigrationError('Legacy backup entry is invalid'); + } + // Node substitutes U+FFFD for unpaired UTF-16 surrogates when mapping + // JavaScript paths to filesystem names. Distinct malicious strings can + // otherwise resolve to the same on-disk file and silently overwrite. + if (Buffer.from(entry.path, 'utf8').toString('utf8') !== entry.path) { + throw new StateMigrationError('Legacy backup path contains ill-formed Unicode'); + } + const pathParts = entry.path.split(/[/\\]+/).filter(Boolean); + // Backups must restore safely on the supported Windows NTFS profile, + // even when validated on a POSIX host. Win32 interprets ':' as an + // alternate-data-stream separator and reserves DOS device basenames, + // including the superscript-digit COM/LPT aliases. Reject these before + // creating any stage, journal, or destination directory. + // https://learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file + const reservedDevice = /^(?:CON|PRN|AUX|NUL|COM[1-9¹²³]|LPT[1-9¹²³])(?:\.|$)/i; + if (pathParts.some((part) => + /[<>:"|?*\x00-\x1f]/u.test(part) || reservedDevice.test(part))) { + throw new StateMigrationError('Unsafe Windows restore path component: ' + entry.path); + } + if (pathParts.some((part) => part !== '.' && part !== '..' && (part.endsWith('.') || part.endsWith(' ')))) { + throw new StateMigrationError('Legacy backup path contains trailing dot or space: ' + entry.path); + } + const content = Buffer.from(entry.contentBase64, 'base64'); + if (content.length !== entry.bytes || stateSha256(content) !== entry.fingerprint) { + throw new StateMigrationError(`Legacy backup entry integrity failed: ${entry.path}`); + } + manifest.push({ path: entry.path, fingerprint: entry.fingerprint, bytes: entry.bytes }); + } + const actual = sha256(JSON.stringify(stable(manifest))); + if (actual !== bundle.sourceFingerprint) { + throw new StateMigrationError('Legacy backup sourceFingerprint does not match entries'); + } + return true; +} + +function persistLegacyBackup(rootDir, bundle) { + validateBackupBundle(bundle); + const paths = ensureStateEngineLayout(rootDir); + const backupPath = path.join(paths.stateRoot, LEGACY_BACKUP_FILE); + if (fs.existsSync(backupPath)) { + const existing = readJson(backupPath, 'Existing legacy backup'); + validateBackupBundle(existing); + if (existing.sourceFingerprint !== bundle.sourceFingerprint) { + throw new StateMigrationError('Refusing to overwrite legacy backup with different source state'); + } + return { backupPath, created: false }; + } + atomicWrite(backupPath, stablePretty(bundle)); + return { backupPath, created: true }; +} + +function autopilotHistory(rootDir) { + const stateDir = path.join(rootDir, '.development-kit', 'autopilot', 'state'); + if (!fs.existsSync(stateDir)) return null; + + const revisionFiles = fs.readdirSync(stateDir) + .filter((name) => /^revision-\d{6}\.json$/.test(name)) + .sort(); + + if (revisionFiles.length === 0) { + if (fs.existsSync(path.join(stateDir, 'current.json'))) { + throw new StateMigrationError('Legacy Autopilot current pointer exists without revision history'); + } + return null; + } + const currentPath = path.join(stateDir, 'current.json'); + if (!fs.existsSync(currentPath)) throw new StateMigrationError('Legacy Autopilot current.json is missing'); + + const pointer = readJson(currentPath, 'Legacy Autopilot current pointer'); + if (!Number.isInteger(pointer.currentRevision) || pointer.currentRevision < 1) { + throw new StateMigrationError('Legacy Autopilot currentRevision is invalid'); + } + const expectedCurrent = `revision-${String(pointer.currentRevision).padStart(6, '0')}.json`; + if (pointer.currentRevisionFile !== expectedCurrent) { + throw new StateMigrationError('Legacy Autopilot current pointer path is inconsistent'); + } + if (!revisionFiles.includes(expectedCurrent)) { + throw new StateMigrationError('Legacy Autopilot current revision file is missing'); + } + + for (let revision = 1; revision <= pointer.currentRevision; revision += 1) { + const expected = `revision-${String(revision).padStart(6, '0')}.json`; + if (!revisionFiles.includes(expected)) { + throw new StateMigrationError(`Legacy Autopilot revision chain is missing ${expected}`); + } + } + + const states = []; + for (const name of revisionFiles) { + const number = Number(name.match(/(\d{6})/)[1]); + if (number > pointer.currentRevision) { + throw new StateMigrationError('Legacy Autopilot contains revisions newer than current pointer'); + } + const state = readJson(path.join(stateDir, name), `Legacy Autopilot ${name}`); + try { validateWorkflowState(state); } catch (error) { + throw new StateMigrationError(`Legacy Autopilot state is invalid: ${name}: ${error.message}`); + } + if (state.stateRevision !== number) { + throw new StateMigrationError(`Legacy Autopilot revision filename/stateRevision mismatch: ${name}`); + } + if (state.workflowId !== pointer.workflowId) { + throw new StateMigrationError(`Legacy Autopilot workflow identity changes within revision chain: ${name}`); + } + states.push(state); + } + + const current = states.at(-1); + if (current.stateRevision !== pointer.currentRevision || current.workflowId !== pointer.workflowId) { + throw new StateMigrationError('Legacy Autopilot current state does not match pointer identity'); + } + + return { + entityType: 'autopilot-workflow', + entityId: current.workflowId, + refs: {}, + states, + current, + }; +} + +function orchestrationHistories(rootDir) { + const runsRoot = path.join(rootDir, '.development-kit', 'runs'); + if (!fs.existsSync(runsRoot)) return []; + const histories = []; + + const contractDirs = fs.readdirSync(runsRoot, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const contractEntry of contractDirs) { + const contractDir = path.join(runsRoot, contractEntry.name); + const runDirs = fs.readdirSync(contractDir, { withFileTypes: true }) + .filter((entry) => entry.isDirectory()) + .sort((a, b) => a.name.localeCompare(b.name)); + + for (const runEntry of runDirs) { + const runDir = path.join(contractDir, runEntry.name); + const manifestPath = path.join(runDir, 'manifest.json'); + if (!fs.existsSync(manifestPath)) { + const revisionDir = path.join(runDir, 'state-revisions'); + const orphanRevisions = fs.existsSync(revisionDir) + && fs.readdirSync(revisionDir).some((name) => /^\d{8}\.json$/.test(name)); + if (orphanRevisions || fs.existsSync(path.join(runDir, 'current-state.json')) + || fs.existsSync(path.join(runDir, 'final-state.json'))) { + throw new StateMigrationError('Legacy orchestration manifest is missing for existing run history'); + } + continue; + } + const manifest = readJson(manifestPath, 'Legacy orchestration manifest'); + try { validateOrchestrationRun(manifest); } catch (error) { + throw new StateMigrationError(`Legacy orchestration manifest is invalid: ${error.message}`); + } + + const revisionDir = path.join(runDir, 'state-revisions'); + let states = []; + if (fs.existsSync(revisionDir)) { + const names = fs.readdirSync(revisionDir) + .filter((name) => /^\d{8}\.json$/.test(name)) + .sort(); + + if (names.length > 0) { + const pointerPath = path.join(runDir, 'current-state.json'); + if (!fs.existsSync(pointerPath)) throw new StateMigrationError('Legacy orchestration current-state.json is missing'); + const pointer = readJson(pointerPath, 'Legacy orchestration current pointer'); + if ( + pointer.contractId !== manifest.contractId + || pointer.runId !== manifest.runId + || !Number.isInteger(pointer.stateRevision) + || pointer.stateRevision < 1 + ) { + throw new StateMigrationError('Legacy orchestration current pointer identity is invalid'); + } + const expectedName = `${String(pointer.stateRevision).padStart(8, '0')}.json`; + if (pointer.revisionPath !== `state-revisions/${expectedName}` || !names.includes(expectedName)) { + throw new StateMigrationError('Legacy orchestration current pointer path is inconsistent'); + } + for (let revision = 1; revision <= pointer.stateRevision; revision += 1) { + const expected = `${String(revision).padStart(8, '0')}.json`; + if (!names.includes(expected)) { + throw new StateMigrationError(`Legacy orchestration revision chain is missing ${expected}`); + } + } + + for (const name of names) { + const number = Number(name.replace('.json', '')); + if (number > pointer.stateRevision) { + throw new StateMigrationError('Legacy orchestration contains revisions newer than current pointer'); + } + const state = readJson(path.join(revisionDir, name), `Legacy orchestration revision ${name}`); + try { validateOrchestrationRun(state); } catch (error) { + throw new StateMigrationError(`Legacy orchestration revision is invalid: ${name}: ${error.message}`); + } + if (state.stateRevision !== number) { + throw new StateMigrationError(`Legacy orchestration filename/stateRevision mismatch: ${name}`); + } + if (state.contractId !== manifest.contractId || state.runId !== manifest.runId) { + throw new StateMigrationError('Legacy orchestration revision identity does not match manifest'); + } + states.push(state); + } + if (canonicalStateJson(states[0]) !== canonicalStateJson(manifest)) { + throw new StateMigrationError('Legacy orchestration revision 1 differs from immutable manifest'); + } + } + } + + if (states.length === 0) { + // A surviving legacy pointer is an acknowledged revision witness. + // Never downgrade it to manifest-only revision 1 when its immutable + // revision files are missing, even if final-state.json is absent. + if (fs.existsSync(path.join(runDir, 'current-state.json'))) { + throw new StateMigrationError( + 'Legacy orchestration current pointer exists without revision history', + ); + } + // Fresh v0.13 runs retain immutable manifests but persist revisions in + // the canonical V2 ledger. A manifest without legacy revision files + // is not a legacy revision-1 history when that run already exists in V2. + const canonicalRun = loadEntityState('orchestration-run', + `${manifest.contractId}/${manifest.runId}`, rootDir); + if (canonicalRun) continue; + states = [manifest]; + } + const current = states.at(-1); + + const finalPath = path.join(runDir, 'final-state.json'); + if (fs.existsSync(finalPath)) { + const finalState = readJson(finalPath, 'Legacy orchestration final-state'); + try { validateOrchestrationRun(finalState); } catch (error) { + throw new StateMigrationError(`Legacy orchestration final-state is invalid: ${error.message}`); + } + if (canonicalStateJson(finalState) !== canonicalStateJson(current)) { + throw new StateMigrationError('Legacy orchestration final-state differs from current state'); + } + } + + histories.push({ + entityType: 'orchestration-run', + entityId: `${manifest.contractId}/${manifest.runId}`, + refs: { + contractId: manifest.contractId, + taskId: manifest.taskId, + runId: manifest.runId, + }, + states, + current, + }); + } + } + + return histories; +} + +export function inspectLegacyState(rootDir = process.cwd()) { + const backupFiles = collectLegacyBackupFiles(rootDir); + const backup = buildBackupBundle(rootDir, backupFiles); + const histories = []; + const autopilot = autopilotHistory(rootDir); + if (autopilot) histories.push(autopilot); + histories.push(...orchestrationHistories(rootDir)); + + return Object.freeze({ + sourceFingerprint: backup.sourceFingerprint, + legacyFileCount: backup.fileCount, + backup, + histories, + entityCount: histories.length, + }); +} + +function migrationId(sourceFingerprint) { + return `mig-v2-${sourceFingerprint.slice('sha256:'.length, 'sha256:'.length + 24)}`; +} + +function completedMigration(sourceFingerprint, rootDir) { + const events = loadCanonicalEvents(rootDir); + return events.find((event) => ( + event.eventType === 'MIGRATION_COMPLETED' + && event.payload?.sourceFingerprint === sourceFingerprint + && event.payload?.semanticEquivalence === true + )) ?? null; +} + +function migrateHistory(history, rootDir) { + for (const legacyState of history.states) { + const current = loadEntityState(history.entityType, history.entityId, rootDir); + if (current) { + if (current.stateRevision > legacyState.stateRevision) continue; + if (current.stateRevision === legacyState.stateRevision) { + if (canonicalStateJson(current) !== canonicalStateJson(legacyState)) { + throw new StateMigrationError( + `Existing State Engine entity differs from legacy state at revision ${legacyState.stateRevision}: ${history.entityType}/${history.entityId}`, + ); + } + continue; + } + } + + appendEntityState({ + rootDir, + entityType: history.entityType, + entityId: history.entityId, + state: legacyState, + refs: history.refs, + actorClass: 'legacy-migration', + timestamp: legacyState.updatedAt ?? legacyState.createdAt ?? new Date().toISOString(), + }); + } +} + +function verifySemanticEquivalence(histories, rootDir) { + const mismatches = []; + for (const history of histories) { + const current = loadEntityState(history.entityType, history.entityId, rootDir); + if (canonicalStateJson(current) !== canonicalStateJson(history.current)) { + mismatches.push({ + entityType: history.entityType, + entityId: history.entityId, + expectedRevision: history.current.stateRevision, + actualRevision: current?.stateRevision ?? null, + }); + } + } + return { + equivalent: mismatches.length === 0, + mismatches, + }; +} + +function migrateLegacyStateToV2Unlocked({ + rootDir = process.cwd(), + completedAt = new Date().toISOString(), +} = {}) { + const inspection = inspectLegacyState(rootDir); + ensureStateEngineLayout(rootDir); + + const existingCompletion = completedMigration(inspection.sourceFingerprint, rootDir); + if (existingCompletion) { + verifyStateEngineIntegrity(rootDir); + const backupPath = path.join(getStateEnginePaths(rootDir).stateRoot, LEGACY_BACKUP_FILE); + if (!fs.existsSync(backupPath)) { + throw new StateMigrationError('Completed migration is missing its recoverable legacy backup'); + } + const backup = readJson(backupPath, 'Existing legacy backup'); + validateBackupBundle(backup); + if (backup.sourceFingerprint !== inspection.sourceFingerprint) { + throw new StateMigrationError('Completed migration backup no longer matches the legacy source fingerprint'); + } + + return Object.freeze({ + migrated: false, + idempotent: true, + migrationId: existingCompletion.payload.migrationId, + sourceFingerprint: inspection.sourceFingerprint, + legacyFileCount: inspection.legacyFileCount, + entityCount: inspection.entityCount, + semanticEquivalence: true, + backupPath, + }); + } + + const backupResult = persistLegacyBackup(rootDir, inspection.backup); + + for (const history of inspection.histories) migrateHistory(history, rootDir); + + const equivalence = verifySemanticEquivalence(inspection.histories, rootDir); + if (!equivalence.equivalent) { + throw new StateMigrationError('Legacy migration semantic-equivalence check failed', equivalence.mismatches); + } + + const id = migrationId(inspection.sourceFingerprint); + appendMetadataEvent({ + rootDir, + eventType: 'MIGRATION_COMPLETED', + actorClass: 'legacy-migration', + timestamp: completedAt, + payload: { + schemaVersion: LEGACY_MIGRATION_SCHEMA_VERSION, + migrationId: id, + sourceFingerprint: inspection.sourceFingerprint, + entityCount: inspection.entityCount, + legacyFileCount: inspection.legacyFileCount, + semanticEquivalence: true, + legacyRetained: true, + backupFile: LEGACY_BACKUP_FILE, + }, + }); + + verifyStateEngineIntegrity(rootDir); + const snapshot = loadStateSnapshot(rootDir, { rebuildIfNeeded: false }); + const migration = snapshot.migrations.find((item) => item.migrationId === id); + if (!migration?.semanticEquivalence) { + throw new StateMigrationError('Migration completion is missing from reconstructed snapshot'); + } + + return Object.freeze({ + migrated: true, + idempotent: false, + migrationId: id, + sourceFingerprint: inspection.sourceFingerprint, + legacyFileCount: inspection.legacyFileCount, + entityCount: inspection.entityCount, + semanticEquivalence: true, + legacyRetained: true, + backupPath: backupResult.backupPath, + }); +} + +// The gate must span inspection, backup publication, all canonical imports, +// equivalence verification and MIGRATION_COMPLETED publication. A writer must +// take the same gate *before* choosing legacy or V2 authority. +export function migrateLegacyStateToV2(options = {}) { + const rootDir = options.rootDir ?? process.cwd(); + return withMigrationGate(rootDir, () => migrateLegacyStateToV2Unlocked(options)); +} + +function resolveSafeRestoreDestination(targetRoot, relativePath, { createMissing = false } = {}) { + const root = path.resolve(targetRoot); + const destination = resolveProjectPath(root, relativePath); + if (createMissing) fs.mkdirSync(root, { recursive: true }); + if (!fs.existsSync(root)) return destination; + if (fs.lstatSync(root).isSymbolicLink()) { + throw new StateMigrationError('Legacy backup restore root must not be a symbolic link'); + } + const rootReal = fs.realpathSync(root); + const components = path.relative(root, destination).split(path.sep); + if (components.some((part) => !part || part === '.' || part === '..' || part.endsWith('.') || part.endsWith(' '))) { + throw new StateMigrationError('Invalid legacy backup restore path'); + } + let current = root; + for (const component of components.slice(0, -1)) { + current = path.join(current, component); + try { + const stat = fs.lstatSync(current); + if (stat.isSymbolicLink()) { + throw new StateMigrationError('Legacy backup restore path contains a symbolic link or junction: ' + relativePath); + } + if (!stat.isDirectory()) throw new StateMigrationError('Legacy backup restore parent is not a directory: ' + relativePath); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + if (createMissing) fs.mkdirSync(current); + else continue; + } + const real = fs.realpathSync(current); + const rel = path.relative(rootReal, real); + if (rel === '..' || rel.startsWith('..' + path.sep) || path.isAbsolute(rel)) { + throw new StateMigrationError('Legacy backup restore escapes its target root: ' + relativePath); + } + } + try { + const existing = fs.lstatSync(destination); + if (!existing.isFile() || existing.isSymbolicLink()) { + throw new StateMigrationError('Legacy backup restore destination must be a regular file: ' + relativePath); + } + if (existing.nlink > 1) { + throw new StateMigrationError('Legacy backup restore destination has multiple hard links: ' + relativePath); + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + return destination; +} + + +export const RESTORE_TRANSACTION_SCHEMA_VERSION = '2.0.0'; +const RESTORE_TERMINAL_PHASE = 'CLEANUP_SAFE'; +const RESTORE_PHASES = Object.freeze([ + 'PREPARED', + 'ORIGINAL_DISPLACED', + 'CANDIDATE_PUBLISHED', + 'PROMOTION_ACKNOWLEDGED', + RESTORE_TERMINAL_PHASE, +]); + +function restoreJournalDigest(targetRoot) { + return crypto.createHash('sha256').update(path.resolve(targetRoot)).digest('hex').slice(0, 24); +} + +/** + * Stable sibling transaction log. It lives outside the root being replaced and + * is intentionally retained after successful restore. New transactions append + * to the same hash-chained JSONL history. + */ +function restoreJournalPath(targetRoot) { + const root = path.resolve(targetRoot); + return path.join(path.dirname(root), '.dk-restore-transaction-' + restoreJournalDigest(root) + '.jsonl'); +} + +/** Pre-v0.13 single-record journal retained for explicit compatibility recovery. */ +function legacyRestoreJournalPath(targetRoot) { + const root = path.resolve(targetRoot); + return path.join(path.dirname(root), '.dk-restore-journal-' + restoreJournalDigest(root) + '.json'); +} + +function restoreRecordHash(record) { + const { recordHash, ...payload } = record; + return stateSha256(Buffer.from(JSON.stringify(stable(payload)))); +} + +function validateRestoreRecord(record, previous = null) { + if (!plainObject(record) || record.schemaVersion !== RESTORE_TRANSACTION_SCHEMA_VERSION) { + throw new StateMigrationError('Restore transaction record schema is invalid'); + } + if (!Number.isInteger(record.sequence) || record.sequence < 1) { + throw new StateMigrationError('Restore transaction sequence is invalid'); + } + if (!/^[a-f0-9]{32}$/.test(record.transactionId || '')) { + throw new StateMigrationError('Restore transactionId is invalid'); + } + if (!RESTORE_PHASES.includes(record.phase)) { + throw new StateMigrationError('Restore transaction phase is invalid'); + } + if (typeof record.timestamp !== 'string' || Number.isNaN(Date.parse(record.timestamp))) { + throw new StateMigrationError('Restore transaction timestamp is invalid'); + } + if (typeof record.targetRoot !== 'string' || path.resolve(record.targetRoot) !== record.targetRoot) { + throw new StateMigrationError('Restore transaction targetRoot is invalid'); + } + if (typeof record.stageName !== 'string' || !/^\.dk-legacy-restore-stage-(?:[a-f0-9]{24}-)?[a-f0-9]{32}$/.test(record.stageName)) { + throw new StateMigrationError('Restore transaction stageName is invalid'); + } + if (record.existed !== true && record.existed !== false) { + throw new StateMigrationError('Restore transaction existed witness is invalid'); + } + if (!/^\d+$/.test(record.stageDev || '') || !/^\d+$/.test(record.stageIno || '')) { + throw new StateMigrationError('Restore transaction stage identity is invalid'); + } + if (record.existed && (!/^\d+$/.test(record.originalDev || '') || !/^\d+$/.test(record.originalIno || ''))) { + throw new StateMigrationError('Restore transaction original identity is invalid'); + } + // Older retained journals lack this witness. They remain readable, but + // cannot authorize a new nonterminal promotion acknowledgement without it. + if (record.candidateFingerprint !== undefined && record.candidateFingerprint !== null && + !/^sha256:[a-f0-9]{64}$/.test(record.candidateFingerprint)) { + throw new StateMigrationError('Restore transaction candidate fingerprint is invalid'); + } + if (previous === null) { + if (record.sequence !== 1 || record.previousRecordHash !== null) { + throw new StateMigrationError('First restore record chain witness is invalid'); + } + } else { + if (record.sequence !== previous.sequence + 1) { + throw new StateMigrationError('Restore transaction sequence is not contiguous'); + } + if (record.previousRecordHash !== previous.recordHash) { + throw new StateMigrationError('Restore transaction previous hash does not match'); + } + } + if (!/^sha256:[a-f0-9]{64}$/.test(record.recordHash || '') + || record.recordHash !== restoreRecordHash(record)) { + throw new StateMigrationError('Restore transaction record hash mismatch'); + } + return true; +} + +function inspectSafeRestoreJournal(journal) { + let stat; + try { stat = fs.lstatSync(journal, { bigint: true }); } + catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) { + throw new StateMigrationError('Unsafe restore transaction journal link or object type; refusing filesystem mutation'); + } + return stat; +} + +function loadRestoreJournal(targetRoot) { + const journal = restoreJournalPath(targetRoot); + if (!inspectSafeRestoreJournal(journal)) return []; + const content = fs.readFileSync(journal, 'utf8'); + if (!content) return []; + if (!content.endsWith('\n')) { + throw new StateMigrationError('Restore transaction log has an incomplete trailing record; preserve it for operator recovery'); + } + const lines = content.split('\n').filter(Boolean); + const records = []; + let previous = null; + for (const line of lines) { + let record; + try { + record = JSON.parse(line); + } catch (error) { + throw new StateMigrationError('Restore transaction log contains invalid JSON: ' + error.message); + } + validateRestoreRecord(record, previous); + records.push(record); + previous = record; + } + return records; +} + +function latestRestoreTransaction(records) { + if (!Array.isArray(records) || records.length === 0) return null; + const transactionId = records.at(-1).transactionId; + const txRecords = records.filter((record) => record.transactionId === transactionId); + const prepared = txRecords.find((record) => record.phase === 'PREPARED'); + if (!prepared) throw new StateMigrationError('Latest restore transaction is missing PREPARED witness'); + return { + prepared, + last: txRecords.at(-1), + records: txRecords, + }; +} + +function appendRestoreTransactionRecord({ + targetRoot, + transaction, + phase, + outcome = null, + now = new Date().toISOString(), +}) { + if (!RESTORE_PHASES.includes(phase)) throw new StateMigrationError('Unknown restore transaction phase: ' + phase); + const journal = restoreJournalPath(targetRoot); + const records = loadRestoreJournal(targetRoot); + const previous = records.at(-1) ?? null; + const base = { + schemaVersion: RESTORE_TRANSACTION_SCHEMA_VERSION, + sequence: (previous?.sequence ?? 0) + 1, + transactionId: transaction.transactionId, + phase, + timestamp: now, + targetRoot: transaction.targetRoot, + stageName: transaction.stageName, + existed: transaction.existed, + originalDev: transaction.originalDev, + originalIno: transaction.originalIno, + stageDev: transaction.stageDev, + stageIno: transaction.stageIno, + candidateFingerprint: transaction.candidateFingerprint ?? null, + outcome, + previousRecordHash: previous?.recordHash ?? null, + }; + const record = { ...base, recordHash: restoreRecordHash(base) }; + + fs.mkdirSync(path.dirname(journal), { recursive: true }); + const prior = inspectSafeRestoreJournal(journal); + const existed = prior !== null; + const noFollow = process.platform !== 'win32' ? (fs.constants.O_NOFOLLOW || 0) : 0; + const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | noFollow | + (existed ? 0 : fs.constants.O_CREAT | fs.constants.O_EXCL); + const fd = fs.openSync(journal, flags, 0o600); + try { + const opened = fs.fstatSync(fd, { bigint: true }); + if (!opened.isFile() || opened.nlink !== 1n || + (prior && (opened.dev !== prior.dev || opened.ino !== prior.ino))) { + throw new StateMigrationError('Restore transaction journal changed identity before append'); + } + fs.writeFileSync(fd, JSON.stringify(record) + '\n', 'utf8'); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + // On first publication this is a namespace durability barrier. On Windows + // it is not the sole witness: orphan stage/displaced directories are also + // detected before any future destructive transaction. + if (!existed) syncRestoreParent(path.dirname(journal)); + return Object.freeze(record); +} + +function orphanRestoreWitnesses(targetRoot) { + const root = path.resolve(targetRoot); + const parent = path.dirname(root); + if (!fs.existsSync(parent)) return []; + const scopedPrefix = '.dk-legacy-restore-stage-' + restoreJournalDigest(root) + '-'; + return fs.readdirSync(parent) + .filter((name) => + // New v0.13 stages are bound to this exact target; retained originals + // belonging to another sibling project must not block its restore. + (name.startsWith(scopedPrefix) && + /^[a-f0-9]{32}(?:-original)?$/.test(name.slice(scopedPrefix.length))) || + // Pre-scoped witnesses cannot be safely attributed without a journal. + // Keep the historical conservative fail-closed behavior for those. + /^\.dk-legacy-restore-stage-[a-f0-9]{32}(?:-original)?$/.test(name)) + .map((name) => path.join(parent, name)) + .sort(); +} + +/** + * Windows path lookup is ordinarily case-insensitive, but the historical + * transaction-log digest is based on the caller's exact path spelling. + * When a published root is missing after a crash, realpath cannot recover + * its original casing. Inspect bounded first-record witnesses in sibling + * journals before treating a differently cased target as a fresh project. + * + * This is a fail-closed alias guard, not proof of physical power-loss + * durability or a defence against a hostile same-account filesystem writer. + */ +/** + * Compare the physical Windows target identity, not caller-supplied spelling. + * GetFinalPathNameByHandle (via realpathSync.native) resolves existing NTFS + * 8.3 ancestor aliases and extended-length namespace spellings. When the + * root was displaced by a crash, canonicalize its surviving parent and + * preserve the leaf name. This is a journal-discovery guard, not a guarantee + * of persistence after sudden power loss. + */ +function windowsCanonicalRestoreRootKey(targetRoot) { + const root = path.resolve(targetRoot); + const parent = path.dirname(root); + const canonicalParent = fs.realpathSync.native(parent); + const canonicalRoot = fs.existsSync(root) + ? fs.realpathSync.native(root) + : path.win32.join(canonicalParent, path.basename(root)); + return path.win32.normalize(canonicalRoot).normalize('NFC').toLowerCase(); +} + +function assertNoWindowsCaseAliasedRestoreJournal(targetRoot) { + if (process.platform !== 'win32') return; + const root = path.resolve(targetRoot); + const parent = path.dirname(root); + if (!fs.existsSync(parent)) return; + const expectedJournal = path.basename(restoreJournalPath(root)).toLowerCase(); + const targetKey = windowsCanonicalRestoreRootKey(root); + for (const name of fs.readdirSync(parent)) { + if (!/^\.dk-restore-transaction-[a-f0-9]{24}\.jsonl$/i.test(name) || + name.toLowerCase() === expectedJournal) continue; + const candidate = path.join(parent, name); + const stat = fs.lstatSync(candidate); + if (!stat.isFile() || stat.isSymbolicLink()) continue; + const fd = fs.openSync(candidate, 'r'); + let firstLine; + try { + const bytes = Buffer.alloc(8192); + const length = fs.readSync(fd, bytes, 0, bytes.length, 0); + firstLine = bytes.subarray(0, length).toString('utf8').split('\n', 1)[0]; + } finally { + fs.closeSync(fd); + } + let witness; + try { witness = JSON.parse(firstLine); } + catch { continue; } + if (typeof witness?.targetRoot !== 'string' || + windowsCanonicalRestoreRootKey(witness.targetRoot) !== targetKey) continue; + validateRestoreRecord(witness); + throw new StateMigrationError( + 'Terminal restore root witness exists under a Windows case alias; refusing destructive mutation', + { code: 'RESTORE_CASE_ALIAS_WITNESS', journal: candidate }, + ); + } +} + +/** + * A Windows journal may disappear from the durable namespace even when its + * scoped stage/displaced-original directory survived. The stage digest then + * cannot be attributed safely to a differently cased missing target root. + * Reject a new restore anywhere in that parent until the orphan is recovered + * by an operator; do not silently treat a sibling name as proof of safety. + */ +function assertNoWindowsOrphanRestoreStages(targetRoot) { + if (process.platform !== 'win32') return; + const parent = path.dirname(path.resolve(targetRoot)); + if (!fs.existsSync(parent)) return; + for (const name of fs.readdirSync(parent)) { + const match = /^\.dk-legacy-restore-stage-([a-f0-9]{24})-[a-f0-9]{32}(?:-original)?$/i.exec(name); + if (!match) continue; + const journal = path.join(parent, '.dk-restore-transaction-' + match[1] + '.jsonl'); + if (!inspectSafeRestoreJournal(journal)) { + throw new StateMigrationError( + 'Orphan restore witnesses exist without a recovery journal; refusing destructive mutation', + { code: 'RESTORE_WITNESS_WITHOUT_JOURNAL', witness: path.join(parent, name) }, + ); + } + } +} + +function assertNoUnjournaledRestoreWitness(targetRoot) { + assertNoWindowsCaseAliasedRestoreJournal(targetRoot); + assertNoWindowsOrphanRestoreStages(targetRoot); + if (fs.existsSync(restoreJournalPath(targetRoot)) || fs.existsSync(legacyRestoreJournalPath(targetRoot))) return; + const witnesses = orphanRestoreWitnesses(targetRoot); + if (witnesses.length > 0) { + throw new StateMigrationError( + 'Restore witnesses exist without a recovery journal; refusing destructive mutation', + { + code: 'RESTORE_WITNESS_WITHOUT_JOURNAL', + witnesses, + }, + ); + } +} + +/** + * Directory-entry ordering barrier for the restore transaction. + * + * POSIX: fsync(2) on the parent directory descriptor. + * Windows: libuv opens directories with FILE_FLAG_BACKUP_SEMANTICS, and + * fs.fsyncSync calls FlushFileBuffers. FlushFileBuffers needs a write-capable + * handle ('r+'); a read-only handle fails EPERM. Microsoft documents a normal + * (flags = 0) flush as writing cached file data *and metadata* and + * synchronizing the storage cache (NtFlushBuffersFileEx; only + * FLUSH_FLAGS_FILE_DATA_SYNC_ONLY is documented invalid for directory + * handles). See docs/04-architecture/dkf-t04-windows-restore-durability-protocol.md + * for the exact guarantee relied on and its limits. + * + * Any failure is fatal: callers never mutate or acknowledge past a barrier + * that did not complete, so the journal and witnesses are retained. + */ +const WINDOWS_DURABILITY_HELPER = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '../../scripts/windows-durable-rename.ps1', +); + +/** + * A successful PowerShell process exit is not sufficient to certify a durable + * rename/flush. Reject incomplete, mismatched or negative helper responses + * before any restore phase can be acknowledged. This validates API evidence, + * not physical power-loss persistence on a particular NTFS device. + */ +export function validateWindowsDurabilityHelperResult(operation, result, expected = {}) { + const invalid = (reason) => { + throw new StateMigrationError('Windows durability helper returned unverified ' + reason, { + code: 'WINDOWS_DURABILITY_HELPER_INVALID_RESULT', + operation, + diagnostic: operation === 'CreateSecureStage' ? { + observedOperation: result?.operation, + observedPath: result?.path, + expectedPath: expected?.Path, + observedProtected: result?.protected, + } : undefined, + }); + }; + if (!result || typeof result !== 'object' || Array.isArray(result)) invalid('result'); + // ACL helper calls are path-scoped and have no storage-profile field. The + // parent restore path was separately probed; accept only an affirmative + // response for the exact operation and expected source/destination paths. + const samePath = (actual, requested) => { + if (typeof actual !== 'string' || typeof requested !== 'string') return false; + if (path.win32.normalize(actual).toLowerCase() === + path.win32.normalize(requested).toLowerCase()) return true; + // Windows may expand an 8.3 path component (RUNNER~1 -> runneradmin) + // while producing the helper response. Require both spellings to resolve + // to the same ordinary directory or file identity; never accept mere + // textual similarity, a reparse point, or a nonexistent destination. + try { + const a = fs.lstatSync(actual, { bigint: true }); + const b = fs.lstatSync(requested, { bigint: true }); + // Rename also applies to the retained V1 journal file, not only to + // directories. Both aliases must identify the same ordinary object + // of the same kind; never follow a symlink or accept a missing path. + const sameKind = (a.isDirectory() && b.isDirectory()) || + (a.isFile() && b.isFile()); + return sameKind && !a.isSymbolicLink() && !b.isSymbolicLink() && + a.ino !== 0n && a.dev === b.dev && a.ino === b.ino; + } catch { + return false; + } + }; + // After a successful rename the source no longer exists, so its inode + // cannot be compared directly. An expanded Windows 8.3 ancestor alias is + // safe to recognize by the identical leaf name and the live identity of + // the source parent directory. Never accept a different leaf or parent. + const sameRenamedSource = (actual, requested) => { + if (samePath(actual, requested)) return true; + if (typeof actual !== 'string' || typeof requested !== 'string') return false; + return path.win32.basename(actual).toLowerCase() === + path.win32.basename(requested).toLowerCase() && + samePath(path.win32.dirname(actual), path.win32.dirname(requested)); + }; + if (operation === 'CreateSecureStage') { + if (result.operation !== operation || result.protected !== true || + !samePath(result.path, expected.Path)) invalid('private stage or path'); + return result; + } + if (operation === 'CopyAclTree') { + if (result.operation !== operation || result.verified !== true || + !samePath(result.source, expected.From) || + !samePath(result.destination, expected.To)) invalid('ACL tree verification or path'); + return result; + } + const profile = operation === 'Probe' ? result : result.profile; + if (!profile || profile.supportedAutomaticRestore !== true + || profile.localFixed !== true + || String(profile.fileSystem || '').toUpperCase() !== 'NTFS') { + invalid('storage profile'); + } + // The helper's success flags alone are insufficient: the response must + // attest to the exact paths requested for this transaction. On Windows, + // samePath also accepts a verified ordinary-directory 8.3 alias. + if (operation === 'Probe') { + if (expected.Path !== undefined && !samePath(profile.path, expected.Path)) { + invalid('storage profile path'); + } + return result; + } + if (result.operation !== operation) invalid('operation'); + if (operation === 'FlushDirectory') { + if (expected.Path !== undefined && !samePath(profile.path, expected.Path)) { + invalid('directory flush path'); + } + if (result.flushed !== true) invalid('directory flush'); + } else if (operation === 'Rename') { + if (expected.From !== undefined && !sameRenamedSource(result.source, expected.From)) { + invalid('rename source path'); + } + if (expected.To !== undefined && !samePath(result.destination, expected.To)) { + invalid('rename destination path'); + } + if (expected.To !== undefined && + !samePath(profile.path, path.win32.dirname(expected.To))) { + invalid('rename parent-directory flush path'); + } + if (result.renamed !== true || result.parentFlushCompleted !== true) { + invalid('rename or parent-directory flush'); + } + } else { + invalid('operation'); + } + return result; +} + +/** + * Resolve the Windows inbox interpreter without libuv's cwd/PATH executable + * search. The SystemRoot process environment is an explicit trusted launcher + * precondition: absent, relative, inconsistent or redirected locations fail + * closed before any journal or restore state can be acknowledged. + */ +function trustedWindowsPowerShellExecutable() { + const systemRoot = process.env.SystemRoot; + const windowsDirectory = process.env.windir; + const invalidRoot = typeof systemRoot !== 'string' || + !/^[a-zA-Z]:\\/.test(systemRoot) || + systemRoot.split(/[\\/]/).some((part) => part === '..' || part === '.'); + if (invalidRoot || (windowsDirectory && + path.win32.normalize(windowsDirectory).toLowerCase() !== + path.win32.normalize(systemRoot).toLowerCase())) { + throw new StateMigrationError('Trusted Windows system directory is unavailable or inconsistent', { + code: 'TRUSTED_WINDOWS_INTERPRETER_UNAVAILABLE', + }); + } + + const executable = path.win32.join(systemRoot, + 'System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + try { + const rootReal = fs.realpathSync.native(systemRoot); + const exeReal = fs.realpathSync.native(executable); + const stat = fs.lstatSync(executable); + const expectedRelative = path.win32.join('System32', 'WindowsPowerShell', 'v1.0', 'powershell.exe'); + if (!stat.isFile() || stat.isSymbolicLink() || + path.win32.relative(rootReal, exeReal).toLowerCase() !== expectedRelative.toLowerCase()) { + throw new Error('System PowerShell executable is not a regular file at its expected location'); + } + return exeReal; + } catch (error) { + throw new StateMigrationError('Trusted Windows PowerShell interpreter cannot be verified', { + code: 'TRUSTED_WINDOWS_INTERPRETER_UNAVAILABLE', + cause: error.code || error.message, + }); + } +} + +function runWindowsDurabilityHelper(operation, args = {}) { + if (process.platform !== 'win32') { + throw new StateMigrationError('Windows durability helper may only run on Windows'); + } + if (!fs.existsSync(WINDOWS_DURABILITY_HELPER)) { + throw new StateMigrationError('Windows durability helper is missing', { + code: 'WINDOWS_DURABILITY_HELPER_MISSING', + helper: WINDOWS_DURABILITY_HELPER, + }); + } + + const argv = [ + '-NoLogo', + '-NoProfile', + '-NonInteractive', + '-ExecutionPolicy', 'Bypass', + '-File', WINDOWS_DURABILITY_HELPER, + '-Operation', operation, + ]; + for (const [key, value] of Object.entries(args)) { + if (value === null || value === undefined) continue; + argv.push('-' + key, String(value)); + } + + const interpreter = trustedWindowsPowerShellExecutable(); + const result = spawnSync(interpreter, argv, { + cwd: path.win32.dirname(interpreter), + encoding: 'utf8', + windowsHide: true, + shell: false, + timeout: 30_000, + }); + if (result.error || result.status !== 0) { + throw new StateMigrationError('Windows durability helper failed; recovery evidence retained', { + code: 'WINDOWS_DURABILITY_HELPER_FAILED', + operation, + status: result.status, + cause: result.error?.message ?? String(result.stderr || result.stdout || 'unknown failure').trim(), + }); + } + + const output = String(result.stdout || '').trim(); + if (!output) { + throw new StateMigrationError('Windows durability helper returned no result'); + } + let parsed; + try { + parsed = JSON.parse(output.split(/\r?\n/).filter(Boolean).at(-1)); + } catch (error) { + throw new StateMigrationError('Windows durability helper returned invalid JSON', { + operation, + cause: error.message, + }); + } + return validateWindowsDurabilityHelperResult(operation, parsed, args); +} + +function requireSupportedWindowsRestoreProfile(targetPath) { + if (process.platform !== 'win32') return null; + const profile = runWindowsDurabilityHelper('Probe', { Path: targetPath }); + if (profile?.supportedAutomaticRestore !== true + || String(profile.fileSystem || '').toUpperCase() !== 'NTFS' + || profile.localFixed !== true) { + throw new StateMigrationError( + 'Windows automatic restore requires a local fixed NTFS durability profile', + { + code: 'UNSUPPORTED_WINDOWS_DURABILITY_TARGET', + profile, + }, + ); + } + return profile; +} + +function syncRestoreParent(parent) { + if (process.platform === 'win32') { + requireSupportedWindowsRestoreProfile(parent); + runWindowsDurabilityHelper('FlushDirectory', { Path: parent }); + return; + } + + let fd; + try { + fd = fs.openSync(parent, 'r'); + fs.fsyncSync(fd); + } catch (error) { + throw new StateMigrationError('Restore directory flush barrier failed; recovery evidence retained', { + code: 'RESTORE_DIRECTORY_FLUSH_FAILED', directory: parent, cause: error.code || error.message, + }); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} + +function durableRestoreRename(from, to) { + const parent = path.dirname(to); + if (process.platform === 'win32') { + requireSupportedWindowsRestoreProfile(parent); + runWindowsDurabilityHelper('Rename', { From: from, To: to }); + return; + } + fs.renameSync(from, to); + syncRestoreParent(parent); +} + +function durableRestoreRollbackRename(from, to) { + durableRestoreRename(from, to); +} + +/** + * Sync the staged file bytes and (on POSIX) every containing directory before + * the promotion journal becomes durable. A rename alone does not durably + * commit nested directory entries after sudden power loss. + */ +/** + * Hash the complete staged candidate namespace before PREPARED. Recovery + * checks the same content witness after interrupted promotion, before writing + * any acknowledgement. Include empty directories and symlink targets; never + * follow symlinks. Do not include root basename, which changes on promotion. + * This is a logical content/namespace check, NOT NTFS physical power-loss + * assurance or a substitute for ACL verification. + */ +function fingerprintRestoreCandidate(directory) { + const records = []; + function visit(absolute, relative) { + const stat = fs.lstatSync(absolute); + const name = relative.split(path.sep).join('/'); + if (stat.isSymbolicLink()) { + records.push({ path: name, kind: 'symlink', target: fs.readlinkSync(absolute) }); + } else if (stat.isDirectory()) { + records.push({ path: name, kind: 'directory' }); + for (const child of fs.readdirSync(absolute).sort()) { + visit(path.join(absolute, child), relative ? path.join(relative, child) : child); + } + } else if (stat.isFile()) { + const hash = crypto.createHash('sha256'); + const fd = fs.openSync(absolute, 'r'); + try { + const chunk = Buffer.allocUnsafe(64 * 1024); + let read; + while ((read = fs.readSync(fd, chunk, 0, chunk.length, null)) > 0) { + hash.update(chunk.subarray(0, read)); + } + } finally { + fs.closeSync(fd); + } + records.push({ path: name, kind: 'file', contentHash: 'sha256:' + hash.digest('hex') }); + } else { + throw new StateMigrationError('Restore candidate contains unsupported filesystem object'); + } + } + visit(directory, ''); + return sha256(JSON.stringify(stable(records))); +} + +function syncRestoreTree(directory) { + for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { + const item = path.join(directory, entry.name); + if (entry.isSymbolicLink()) continue; // retain links as links, never follow + if (entry.isDirectory()) { + syncRestoreTree(item); + } else if (entry.isFile()) { + // FlushFileBuffers on Windows requires a write-capable handle; a + // read-only descriptor can fail EPERM even when the file is writable. + const fd = fs.openSync(item, process.platform === 'win32' ? 'r+' : 'r'); + try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + } else { + throw new StateMigrationError('Legacy restore stage contains an unsupported special file'); + } + } + syncRestoreParent(directory); +} + +function inspectRestorePath(file) { + try { + return fs.lstatSync(file, { bigint: true }); + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } +} + +function sameRestoreIdentity(stat, dev, ino) { + return Boolean(stat) + && stat.isDirectory() + && !stat.isSymbolicLink() + && stat.dev.toString() === dev + && stat.ino.toString() === ino; +} + +function appendMissingSuccessfulRestorePhases(targetRoot, transaction, existingRecords, { outcome = 'promoted' } = {}) { + const phases = new Set(existingRecords.map((record) => record.phase)); + if (transaction.existed && !phases.has('ORIGINAL_DISPLACED')) { + appendRestoreTransactionRecord({ targetRoot, transaction, phase: 'ORIGINAL_DISPLACED' }); + phases.add('ORIGINAL_DISPLACED'); + } + if (!phases.has('CANDIDATE_PUBLISHED')) { + appendRestoreTransactionRecord({ targetRoot, transaction, phase: 'CANDIDATE_PUBLISHED' }); + phases.add('CANDIDATE_PUBLISHED'); + } + if (!phases.has('PROMOTION_ACKNOWLEDGED')) { + appendRestoreTransactionRecord({ targetRoot, transaction, phase: 'PROMOTION_ACKNOWLEDGED' }); + phases.add('PROMOTION_ACKNOWLEDGED'); + } + if (!phases.has(RESTORE_TERMINAL_PHASE)) { + appendRestoreTransactionRecord({ + targetRoot, + transaction, + phase: RESTORE_TERMINAL_PHASE, + outcome, + }); + } +} + +function retainRecoveredLegacyRestoreJournal(journalPath, transactionId) { + const retained = journalPath + '.retained-' + transactionId + '.json'; + if (fs.existsSync(retained)) { + throw new StateMigrationError('Retained legacy restore journal already exists; refusing ambiguous cleanup', { + code: 'LEGACY_RESTORE_RETAINED_JOURNAL_CONFLICT', + retained, + }); + } + durableRestoreRename(journalPath, retained); + return retained; +} + +function recoverLegacyRestoreV1({ root, parent, journalPath }) { + const tx = readJson(journalPath, 'Legacy restore journal'); + const expectedStage = '.dk-legacy-restore-stage-' + tx.transactionId; + const stage = path.join(parent, expectedStage); + const displaced = stage + '-original'; + if (!/^[a-f0-9]{32}$/.test(tx.transactionId || '') + || tx.targetRoot !== root + || tx.schemaVersion !== '1.0.0' + || tx.stageName !== expectedStage + || (tx.existed !== true && tx.existed !== false) + || !/^\d+$/.test(tx.stageDev || '') + || !/^\d+$/.test(tx.stageIno || '') + || (tx.existed && (!/^\d+$/.test(tx.originalDev || '') || !/^\d+$/.test(tx.originalIno || '')))) { + throw new StateMigrationError('Legacy restore journal contains an invalid transaction witness'); + } + + if (process.platform === 'win32') requireSupportedWindowsRestoreProfile(parent); + + const atRoot = inspectRestorePath(root); + const inStage = inspectRestorePath(stage); + const inDisplaced = inspectRestorePath(displaced); + if (inStage && !sameRestoreIdentity(inStage, tx.stageDev, tx.stageIno)) { + throw new StateMigrationError('Unrecognized staged restore directory; refusing v1 recovery'); + } + if (inDisplaced && (!tx.existed || !sameRestoreIdentity(inDisplaced, tx.originalDev, tx.originalIno))) { + throw new StateMigrationError('Displaced restore directory differs from v1 original witness'); + } + + let outcome; + if (inDisplaced && !atRoot) { + durableRestoreRollbackRename(displaced, root); + outcome = 'rolled-back-original'; + } else if (inDisplaced && sameRestoreIdentity(atRoot, tx.stageDev, tx.stageIno)) { + outcome = 'promoted-original-retained'; + } else if (!inDisplaced && ( + (!atRoot && !tx.existed) + || (tx.existed && sameRestoreIdentity(atRoot, tx.originalDev, tx.originalIno)) + )) { + outcome = 'uncommitted-original-unchanged'; + } else if (!tx.existed && sameRestoreIdentity(atRoot, tx.stageDev, tx.stageIno)) { + outcome = 'promoted-new-root'; + } else { + throw new StateMigrationError('Ambiguous v1 restore journal state; refusing automatic mutation'); + } + + if (inStage && fs.existsSync(stage)) { + fs.rmSync(stage, { recursive: true, force: true }); + syncRestoreParent(parent); + } + + const transaction = { + transactionId: tx.transactionId, + targetRoot: root, + stageName: expectedStage, + existed: tx.existed, + originalDev: tx.originalDev ?? null, + originalIno: tx.originalIno ?? null, + stageDev: tx.stageDev, + stageIno: tx.stageIno, + }; + appendRestoreTransactionRecord({ + targetRoot: root, + transaction, + phase: 'PREPARED', + outcome: 'imported-v1-recovery', + }); + appendRestoreTransactionRecord({ + targetRoot: root, + transaction, + phase: RESTORE_TERMINAL_PHASE, + outcome: 'legacy-v1-' + outcome, + }); + + const retainedJournal = retainRecoveredLegacyRestoreJournal(journalPath, tx.transactionId); + return Object.freeze({ + recovered: true, + legacyJournal: true, + outcome, + retainedJournal, + displacedOriginal: outcome === 'promoted-original-retained' ? displaced : null, + }); +} + +function requireTerminalRestoreRootWitness(root, transaction, rawOutcome) { + // Historical V1 journals are imported with a legacy-v1- outcome prefix. + // Normalize only that authenticated import marker; all other outcomes must + // match the closed terminal outcome set and the exact on-disk root inode. + const outcome = typeof rawOutcome === 'string' && rawOutcome.startsWith('legacy-v1-') + ? rawOutcome.slice('legacy-v1-'.length) : rawOutcome; + const rootWitness = inspectRestorePath(root); + const promoted = outcome === 'promoted-new-root' || outcome === 'promoted-original-retained'; + const original = outcome === 'rolled-back-original' || outcome === 'uncommitted-original-unchanged'; + const witnessValid = promoted + ? sameRestoreIdentity(rootWitness, transaction.stageDev, transaction.stageIno) + : original + ? transaction.existed + ? sameRestoreIdentity(rootWitness, transaction.originalDev, transaction.originalIno) + : !rootWitness + : false; + if (!witnessValid) { + throw new StateMigrationError('Terminal restore root identity witness mismatch; refusing to supersede recovery evidence'); + } + return true; +} + +/** + * Recover only an interrupted OFFLINE restore for the exact target root. + * Recovery is witness-driven and never treats absence of a journal entry as + * proof that no transaction existed. + */ +export function recoverLegacyRestore({ targetRoot, confirmOffline = false } = {}) { + if (typeof targetRoot !== 'string' || !targetRoot) { + throw new StateMigrationError('targetRoot is required for restore recovery'); + } + if (confirmOffline !== true) { + throw new StateMigrationError('Explicit offline confirmation is required for restore recovery'); + } + + const root = path.resolve(targetRoot); + const parent = path.dirname(root); + assertNoWindowsCaseAliasedRestoreJournal(root); + assertNoWindowsOrphanRestoreStages(root); + const legacyJournal = legacyRestoreJournalPath(root); + if (fs.existsSync(legacyJournal)) { + return recoverLegacyRestoreV1({ root, parent, journalPath: legacyJournal }); + } + + const records = loadRestoreJournal(root); + if (records.length === 0) { + assertNoUnjournaledRestoreWitness(root); + return Object.freeze({ recovered: false }); + } + + const latest = latestRestoreTransaction(records); + const tx = latest.prepared; + const legacyStage = '.dk-legacy-restore-stage-' + tx.transactionId; + const scopedStage = '.dk-legacy-restore-stage-' + restoreJournalDigest(root) + '-' + tx.transactionId; + if (tx.targetRoot !== root || ![legacyStage, scopedStage].includes(tx.stageName)) { + throw new StateMigrationError('Restore transaction witness does not match requested target root'); + } + + const stage = path.join(parent, tx.stageName); + const displaced = stage + '-original'; + + if (latest.last.phase === RESTORE_TERMINAL_PHASE) { + // Never treat an authenticated terminal record as physical root evidence. + requireTerminalRestoreRootWitness(root, tx, latest.last.outcome); + return Object.freeze({ + recovered: false, + alreadyComplete: true, + transactionId: tx.transactionId, + outcome: latest.last.outcome, + transactionLog: restoreJournalPath(root), + }); + } + + if (process.platform === 'win32') requireSupportedWindowsRestoreProfile(parent); + + const atRoot = inspectRestorePath(root); + const inStage = inspectRestorePath(stage); + const inDisplaced = inspectRestorePath(displaced); + + if (inStage && !sameRestoreIdentity(inStage, tx.stageDev, tx.stageIno)) { + throw new StateMigrationError('Unrecognized staged restore directory; refusing recovery'); + } + if (inDisplaced && (!tx.existed || !sameRestoreIdentity(inDisplaced, tx.originalDev, tx.originalIno))) { + throw new StateMigrationError('Displaced restore directory differs from original witness'); + } + + // Crash after original displacement but before candidate publication: restore + // the authenticated original and terminate the transaction as rolled back. + if (inDisplaced && !atRoot) { + durableRestoreRollbackRename(displaced, root); + if (inStage && fs.existsSync(stage)) { + fs.rmSync(stage, { recursive: true, force: true }); + syncRestoreParent(parent); + } + appendRestoreTransactionRecord({ + targetRoot: root, + transaction: tx, + phase: RESTORE_TERMINAL_PHASE, + outcome: 'rolled-back-original', + }); + return Object.freeze({ + recovered: true, + outcome: 'rolled-back-original', + transactionId: tx.transactionId, + transactionLog: restoreJournalPath(root), + }); + } + + // Candidate is already visible. Finish only the missing durable state + // records, then make old-root cleanup a post-terminal maintenance action. + if (sameRestoreIdentity(atRoot, tx.stageDev, tx.stageIno) + && ((!tx.existed && !inDisplaced) + || (tx.existed && inDisplaced))) { + // A matching root inode does not prove its descendants survived a + // crash. A nonterminal promotion must be bound to the full staged tree + // witnessed before PREPARED, not acknowledged on inode identity alone. + // Missing witnesses in older journals require explicit offline review. + if (!/^sha256:[a-f0-9]{64}$/.test(tx.candidateFingerprint || '')) { + throw new StateMigrationError('Missing candidate content fingerprint; refusing restore acknowledgement'); + } + const observedFingerprint = fingerprintRestoreCandidate(root); + if (observedFingerprint !== tx.candidateFingerprint) { + throw new StateMigrationError('Promoted candidate content fingerprint mismatch; preserving original and journal'); + } + // The candidate may be visible after a successful rename whose parent + // directory flush failed or never ran before process death. Retry the + // publication namespace barrier *before* appending acknowledgement or + // terminal records. On failure, preserve the nonterminal journal and both + // candidate/original witnesses for another controlled recovery attempt. + syncRestoreParent(parent); + appendMissingSuccessfulRestorePhases(root, tx, latest.records, { + outcome: tx.existed ? 'promoted-original-retained' : 'promoted-new-root', + }); + + // The old root is the only exact-inode rollback witness after promotion. + // A terminal journal record cannot certify physical persistence of the + // preceding Windows namespace operations. Retain it for separate offline + // reviewed cleanup; never delete it as part of automatic recovery. + let cleanupPending = tx.existed && fs.existsSync(displaced); + if (inStage && fs.existsSync(stage)) { + try { + fs.rmSync(stage, { recursive: true, force: true }); + syncRestoreParent(parent); + } catch { + cleanupPending = true; + } + } + + return Object.freeze({ + recovered: true, + outcome: tx.existed ? 'promoted-original-retained' : 'promoted-new-root', + transactionId: tx.transactionId, + transactionLog: restoreJournalPath(root), + cleanupPending, + displacedOriginal: cleanupPending && tx.existed ? displaced : null, + }); + } + + // No destructive rename persisted. Preserve the authenticated original, drop + // only the authenticated staging copy, and terminally record rollback. + if (!inDisplaced && ( + (tx.existed && sameRestoreIdentity(atRoot, tx.originalDev, tx.originalIno)) + || (!tx.existed && !atRoot) + )) { + if (inStage && fs.existsSync(stage)) { + fs.rmSync(stage, { recursive: true, force: true }); + syncRestoreParent(parent); + } + appendRestoreTransactionRecord({ + targetRoot: root, + transaction: tx, + phase: RESTORE_TERMINAL_PHASE, + outcome: 'uncommitted-original-unchanged', + }); + return Object.freeze({ + recovered: true, + outcome: 'uncommitted-original-unchanged', + transactionId: tx.transactionId, + transactionLog: restoreJournalPath(root), + }); + } + + throw new StateMigrationError('Ambiguous restore transaction state; refusing automatic mutation', { + code: 'AMBIGUOUS_RESTORE_TRANSACTION', + transactionId: tx.transactionId, + }); +} + +/** + * Snapshot descendant metadata before copying: cpSync recreates ownership + * under the maintenance identity and may update source directory atimes. + * Post-order records let directories receive their final times only after + * all nested file handles have been visited. + */ +function collectRestoreMetadata(source, relative = '') { + const stat = fs.lstatSync(source); + const children = stat.isDirectory() + ? fs.readdirSync(source).flatMap(name => collectRestoreMetadata( + path.join(source, name), relative ? path.join(relative, name) : name, + )) + : []; + return [...children, { + relative, mode: stat.mode & 0o7777, + uid: stat.uid, gid: stat.gid, + atime: stat.atime, mtime: stat.mtime, + kind: stat.isDirectory() ? 'directory' : stat.isFile() ? 'file' : stat.isSymbolicLink() ? 'symlink' : 'unsupported', + }]; +} + +function applyRestoreMetadata(stage, records) { + for (const entry of records) { + const destination = entry.relative ? path.join(stage, entry.relative) : stage; + const current = fs.lstatSync(destination); + const sameKind = entry.kind === 'directory' ? current.isDirectory() + : entry.kind === 'file' ? current.isFile() + : entry.kind === 'symlink' ? current.isSymbolicLink() : false; + if (!sameKind) throw new StateMigrationError('Staged restore object type differs from original metadata'); + if (process.platform !== 'win32' && + (current.uid !== entry.uid || current.gid !== entry.gid)) { + if (entry.kind === 'symlink') fs.lchownSync(destination, entry.uid, entry.gid); + else fs.chownSync(destination, entry.uid, entry.gid); + } + if (entry.kind === 'symlink') continue; // never follow + fs.chmodSync(destination, entry.mode); + fs.utimesSync(destination, entry.atime, entry.mtime); + // Persist restored metadata, and avoid readdir once parent atime is set. + if (entry.kind === 'directory') { + syncRestoreParent(destination); + } else if (process.platform !== 'win32') { + const fd = fs.openSync(destination, 'r'); + try { fs.fsyncSync(fd); } finally { fs.closeSync(fd); } + } + } +} + +export function restoreLegacyBackup({ + backupPath, + targetRoot, + overwrite = false, + confirmOffline = false, +} = {}) { + if (typeof backupPath !== 'string' || !backupPath) throw new StateMigrationError('backupPath is required'); + if (typeof targetRoot !== 'string' || !targetRoot) throw new StateMigrationError('targetRoot is required'); + if (confirmOffline !== true) { + throw new StateMigrationError('Legacy backup restoration requires explicit offline confirmation and an exclusively controlled local workspace'); + } + + const resolvedRoot = path.resolve(targetRoot); + const parent = path.dirname(resolvedRoot); + assertNoUnjournaledRestoreWitness(resolvedRoot); + + const legacyJournal = legacyRestoreJournalPath(resolvedRoot); + if (fs.existsSync(legacyJournal)) { + throw new StateMigrationError('Incomplete pre-v0.13 legacy restore exists; run recoverLegacyRestore with offline confirmation first'); + } + + const priorRecords = loadRestoreJournal(resolvedRoot); + if (priorRecords.length > 0) { + const latest = latestRestoreTransaction(priorRecords); + if (latest.last.phase !== RESTORE_TERMINAL_PHASE) { + throw new StateMigrationError('Incomplete legacy restore exists; run recoverLegacyRestore with offline confirmation first', { + code: 'INCOMPLETE_RESTORE_TRANSACTION', + transactionId: latest.prepared.transactionId, + phase: latest.last.phase, + }); + } + // A second restore may not overwrite or supersede a terminal journal + // whose prior root publication was lost across power interruption. + requireTerminalRestoreRootWitness(resolvedRoot, latest.prepared, latest.last.outcome); + } + + const bundle = readJson(backupPath, 'Legacy backup'); + validateBackupBundle(bundle); + + // Complete a full destination security and overwrite preflight before writing + // even one byte; a later malicious backup path cannot leave a partial restore. + const destinations = bundle.files.map((entry) => ({ + entry, + destination: resolveSafeRestoreDestination(resolvedRoot, entry.path), + })); + const normalizedDestinations = new Set(); + for (const { entry, destination } of destinations) { + const key = destination + .normalize('NFC') + // NTFS filename collation is based on uppercase mappings; lowercase + // folding misses valid aliases such as U+017F (long s) versus s. + // This is conservative only: actual NTFS $UpCase tables may differ + // from JavaScript Unicode, so the staged physical identity guard below + // remains mandatory for 8.3 and volume-specific aliases. + .toUpperCase() + .split(/[\\/]+/) + .map((part) => part.replace(/[. ]+$/, '')) + .join('/'); + if (normalizedDestinations.has(key)) { + throw new StateMigrationError('Duplicate normalized backup restore destination: ' + entry.path); + } + normalizedDestinations.add(key); + } + for (const { entry, destination } of destinations) { + if (fs.existsSync(destination) && !overwrite) { + throw new StateMigrationError('Refusing to overwrite restored legacy path: ' + entry.path); + } + } + + const originalRootExists = fs.existsSync(resolvedRoot); + const rootStat = originalRootExists ? fs.lstatSync(resolvedRoot) : null; + const originalMetadata = originalRootExists ? collectRestoreMetadata(resolvedRoot) : []; + const rootIdentity = originalRootExists ? fs.lstatSync(resolvedRoot, { bigint: true }) : null; + if (rootStat && (!rootStat.isDirectory() || rootStat.isSymbolicLink())) { + throw new StateMigrationError('Legacy backup restore root must be an ordinary directory'); + } + + // Windows support is positive/capability based. Unsupported storage fails + // before the staging tree or any destructive target-root mutation exists. + if (process.platform === 'win32') requireSupportedWindowsRestoreProfile(parent); + syncRestoreParent(parent); + + const transactionId = crypto.randomBytes(16).toString('hex'); + const stage = path.join(parent, '.dk-legacy-restore-stage-' + + restoreJournalDigest(resolvedRoot) + '-' + transactionId); + const displaced = stage + '-original'; + if (process.platform === 'win32') { + // Create a private protected NTFS DACL atomically. Keep it private while + // copying sensitive descendants, then restore exact source ACLs in + // post-order before publishing the candidate. + runWindowsDurabilityHelper('CreateSecureStage', { + Path: stage, ...(originalRootExists ? { From: resolvedRoot } : {}), + }); + } else { + fs.mkdirSync(stage, { mode: 0o700 }); + } + + let promoted = false; + let displacedOriginal = false; + let transaction = null; + + try { + if (process.platform !== 'win32') fs.chmodSync(stage, 0o700); + if (originalRootExists) { + fs.cpSync(resolvedRoot, stage, { + recursive: true, + force: true, + dereference: false, + verbatimSymlinks: true, + }); + } + + const restored = []; + // A second signed path can name an already restored physical file via + // NTFS 8.3 or volume-specific $UpCase aliases even when string folding + // finds no duplicate. Check the file identity before every replacement. + const writtenStageFileIds = new Set(); + for (const { entry } of destinations) { + const destination = resolveSafeRestoreDestination(stage, entry.path, { createMissing: true }); + try { + const existing = fs.lstatSync(destination, { bigint: true }); + if (writtenStageFileIds.has(existing.dev.toString() + ':' + existing.ino.toString())) { + throw new StateMigrationError('Duplicate physical restore destination alias: ' + entry.path); + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + const content = Buffer.from(entry.contentBase64, 'base64'); + const tempFile = destination + '.tmp-' + crypto.randomUUID(); + const fd = fs.openSync(tempFile, 'wx', 0o600); + try { + fs.writeFileSync(fd, content); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + fs.renameSync(tempFile, destination); + const writtenIdentity = fs.lstatSync(destination, { bigint: true }); + writtenStageFileIds.add(writtenIdentity.dev.toString() + ':' + writtenIdentity.ino.toString()); + if (stateSha256(fs.readFileSync(destination)) !== entry.fingerprint) { + throw new StateMigrationError('Staged legacy file failed fingerprint verification: ' + entry.path); + } + restored.push(entry.path); + } + + syncRestoreTree(stage); + // Fingerprint content before restoring historical atimes: walking the + // staged tree to hash bytes would otherwise advance its access times. + const candidateFingerprint = fingerprintRestoreCandidate(stage); + if (rootStat) applyRestoreMetadata(stage, originalMetadata); + if (process.platform === 'win32' && originalRootExists) { + // Child ACLs are installed while the stage root is still private. + // CopyAclTree applies the root ACL last, then re-verifies the whole + // tree so inherited ACE propagation cannot silently widen children. + runWindowsDurabilityHelper('CopyAclTree', { From: resolvedRoot, To: stage }); + syncRestoreTree(stage); + } + + const stageStat = fs.lstatSync(stage, { bigint: true }); + transaction = { + transactionId, + stageName: path.basename(stage), + targetRoot: resolvedRoot, + existed: originalRootExists, + originalDev: rootIdentity?.dev.toString() ?? null, + originalIno: rootIdentity?.ino.toString() ?? null, + stageDev: stageStat.dev.toString(), + stageIno: stageStat.ino.toString(), + candidateFingerprint, + }; + + // W73-01: durable discoverable recovery intent precedes every destructive + // root-directory rename. + appendRestoreTransactionRecord({ + targetRoot: resolvedRoot, + transaction, + phase: 'PREPARED', + }); + + if (originalRootExists) { + durableRestoreRename(resolvedRoot, displaced); + displacedOriginal = true; + + const moved = fs.lstatSync(displaced, { bigint: true }); + if (!sameRestoreIdentity(moved, transaction.originalDev, transaction.originalIno)) { + throw new StateMigrationError('Legacy backup restore root changed during staging'); + } + appendRestoreTransactionRecord({ + targetRoot: resolvedRoot, + transaction, + phase: 'ORIGINAL_DISPLACED', + }); + } + + // W73-03: publish candidate using the platform durability primitive. + durableRestoreRename(stage, resolvedRoot); + promoted = true; + displacedOriginal = false; + + const published = fs.lstatSync(resolvedRoot, { bigint: true }); + if (!sameRestoreIdentity(published, transaction.stageDev, transaction.stageIno)) { + throw new StateMigrationError('Published restore candidate differs from staged identity witness'); + } + appendRestoreTransactionRecord({ + targetRoot: resolvedRoot, + transaction, + phase: 'CANDIDATE_PUBLISHED', + }); + + // Journal acknowledgement is an explicit persistent state transition, not + // deletion of the only recovery authority. + appendRestoreTransactionRecord({ + targetRoot: resolvedRoot, + transaction, + phase: 'PROMOTION_ACKNOWLEDGED', + }); + appendRestoreTransactionRecord({ + targetRoot: resolvedRoot, + transaction, + phase: RESTORE_TERMINAL_PHASE, + outcome: originalRootExists ? 'promoted-original-retained' : 'promoted-new-root', + }); + + // A completed restore retains the displaced original as an exact-inode + // recovery witness. Its later disposal is a distinct offline operation, + // requiring explicit retention/security approval and disk-space planning. + // Do not destroy the original merely because the terminal log was fsynced. + const cleanupPending = originalRootExists && fs.existsSync(displaced); + + return Object.freeze({ + restoredFiles: restored.sort(), + sourceFingerprint: bundle.sourceFingerprint, + transactionId, + transactionLog: restoreJournalPath(resolvedRoot), + cleanupPending, + displacedOriginal: cleanupPending && originalRootExists ? displaced : null, + }); + } finally { + if (displacedOriginal) { + try { + if (!fs.existsSync(resolvedRoot)) { + durableRestoreRollbackRename(displaced, resolvedRoot); + } + } catch { + // Explicit recoverLegacyRestore remains the authority after any + // ambiguous/failed in-process rollback. + } + } + if (!promoted) { + try { + if (fs.existsSync(stage)) { + fs.rmSync(stage, { recursive: true, force: true }); + syncRestoreParent(parent); + } + } catch { + // Retain orphan stage witness on cleanup failure; future restore will + // fail closed if no matching transaction log can be proven. + } + } + } +} + +export function getLegacyBackupPath(rootDir = process.cwd()) { + return path.join(getStateEnginePaths(rootDir).stateRoot, LEGACY_BACKUP_FILE); +} + +export { validateBackupBundle }; diff --git a/runtime/orchestration/state-engine-v2.mjs b/runtime/orchestration/state-engine-v2.mjs new file mode 100644 index 000000000..348c59318 --- /dev/null +++ b/runtime/orchestration/state-engine-v2.mjs @@ -0,0 +1,2132 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { isDeepStrictEqual } from 'node:util'; + +export const STATE_ENGINE_SCHEMA_VERSION = '2.0.0'; +export const STATE_EVENT_SCHEMA_VERSION = '1.0.0'; +export const STATE_SNAPSHOT_SCHEMA_VERSION = '2.0.0'; +export const STATE_INDEX_SCHEMA_VERSION = '1.0.0'; + +export const STATE_ENGINE_ROOT = '.development-kit/state'; +export const STATE_EVENTS_FILE = 'events.jsonl'; +export const STATE_SNAPSHOT_FILE = 'snapshot.json'; +export const STATE_INDEX_FILE = 'index.db'; +export const STATE_SCHEMA_FILE = 'schema.json'; + +const SHA256_PATTERN = /^sha256:[a-f0-9]{64}$/; +const EVENT_TYPE_PATTERN = /^[A-Z][A-Z0-9_]{1,95}$/; +const ENTITY_TYPE_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/; +const MAX_ENTITY_ID_LENGTH = 512; +// Bounded blocking sleeps avoid starving a contended local state writer. +const STATE_LOCK_SLEEP = new Int32Array(new SharedArrayBuffer(4)); +function waitStateLock(ms = 25) { Atomics.wait(STATE_LOCK_SLEEP, 0, 0, ms); } + +export class StateEngineError extends Error { + constructor(message, details = null) { + super(message); + this.name = 'StateEngineError'; + this.details = details; + } +} + +function plainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (!value || typeof value !== 'object') return value; + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])])); +} + +function stableJson(value) { + return JSON.stringify(stable(value)); +} + +function stablePretty(value) { + return `${JSON.stringify(stable(value), null, 2)}\n`; +} + +function sha256(value) { + return `sha256:${crypto.createHash('sha256').update(value).digest('hex')}`; +} + +function requireText(value, label) { + if (typeof value !== 'string' || !value.trim()) { + throw new StateEngineError(`${label} must be a non-empty string`); + } + return value.trim(); +} + +function optionalText(value, label) { + if (value === undefined || value === null) return null; + return requireText(value, label); +} + +function entityType(value) { + const normalized = requireText(value, 'entityType'); + if (!ENTITY_TYPE_PATTERN.test(normalized)) { + throw new StateEngineError('entityType contains unsupported characters'); + } + return normalized; +} + +function entityId(value) { + const normalized = requireText(value, 'entityId'); + if (normalized.length > MAX_ENTITY_ID_LENGTH || /[\r\n\0]/.test(normalized)) { + throw new StateEngineError('entityId is invalid'); + } + return normalized; +} + +function eventType(value) { + const normalized = requireText(value, 'eventType'); + if (!EVENT_TYPE_PATTERN.test(normalized)) { + throw new StateEngineError('eventType is invalid'); + } + return normalized; +} + +function timestamp(value, label = 'timestamp') { + if (typeof value !== 'string' || Number.isNaN(Date.parse(value))) { + throw new StateEngineError(`${label} must be a valid timestamp`); + } + return value; +} + +function stateRevision(value, { allowNull = false } = {}) { + if (allowNull && (value === undefined || value === null)) return null; + if (!Number.isInteger(value) || value < 1) { + throw new StateEngineError('stateRevision must be a positive integer'); + } + return value; +} + +function statePaths(rootDir = process.cwd(), { recoverBootstrapIfNeeded = true } = {}) { + const root = path.resolve(rootDir); + const stateRoot = path.resolve(root, STATE_ENGINE_ROOT); + const relative = path.relative(root, stateRoot); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new StateEngineError('State Engine path escapes project root'); + } + const paths = Object.freeze({ + root, + stateRoot, + events: path.join(stateRoot, STATE_EVENTS_FILE), + snapshot: path.join(stateRoot, STATE_SNAPSHOT_FILE), + index: path.join(stateRoot, STATE_INDEX_FILE), + schema: path.join(stateRoot, STATE_SCHEMA_FILE), + lock: path.join(stateRoot, 'state.lock'), + pending: path.join(stateRoot, 'pending-commit.json'), + }); + if (fs.existsSync(stateRoot)) { + assertStateRootRealpathSafe(paths); + assertStateFilesNotLinks(paths, { recoverBootstrapIfNeeded }); + } + return paths; +} + +function assertStateRootRealpathSafe(paths) { + const rootReal = fs.realpathSync(paths.root); + const stateReal = fs.realpathSync(paths.stateRoot); + const relative = path.relative(rootReal, stateReal); + if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative)) { + throw new StateEngineError('State Engine root resolves outside project root'); + } + return stateReal; +} + +function assertStateFilesNotLinks(paths, { recoverBootstrapIfNeeded = true } = {}) { + for (const field of ['events', 'snapshot', 'index', 'schema', 'pending', 'lock']) { + try { + const stat = fs.lstatSync(paths[field]); + if (stat.isSymbolicLink() || !stat.isFile()) { + throw new StateEngineError('State Engine internal file is not a regular file: ' + field); + } + if (stat.nlink > 1) { + // The common path must not pay for extra filesystem probes on every + // state read/write. Reconcile only an actual multiply-linked bootstrap + // schema/snapshot/index; all other hard links remain forbidden. + if (recoverBootstrapIfNeeded && ['schema', 'snapshot', 'index'].includes(field)) { + recoverInterruptedBootstrapPublication(paths); + } + if (fs.lstatSync(paths[field]).nlink > 1) { + throw new StateEngineError('State Engine internal file has multiple hard links: ' + field); + } + } + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } +} + +/** + * Recover only the exact private staging hard link left by a terminated + * first-layout initializer between linkSync() and unlinkSync(). No other + * multiply-linked canonical file is eligible. A live/unknown owner, unknown + * content, non-regular path, or different inode remains a hard failure. + * This does not relax the trusted/exclusive local-runtime threat boundary. + */ +function recoverInterruptedBootstrapPublication(paths) { + for (const field of ['schema', 'snapshot', 'index']) { + const canonical = paths[field]; + let stat; + try { stat = fs.lstatSync(canonical, { bigint: true }); } + catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 2n) continue; + + const prefix = path.basename(canonical) + '.tmp-'; + const matches = []; + for (const name of fs.readdirSync(paths.stateRoot)) { + if (!name.startsWith(prefix)) continue; + const suffix = name.slice(prefix.length); + const ownerMatch = /^([1-9][0-9]*)-([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/.exec(suffix); + if (!ownerMatch) continue; + const candidate = path.join(paths.stateRoot, name); + const candidateStat = fs.lstatSync(candidate, { bigint: true }); + if (candidateStat.isFile() && !candidateStat.isSymbolicLink() + && candidateStat.dev === stat.dev && candidateStat.ino === stat.ino + && candidateStat.nlink === 2n) { + matches.push({ name, candidate, pid: Number(ownerMatch[1]) }); + } + } + if (matches.length !== 1 || !Number.isSafeInteger(matches[0].pid)) { + throw new StateEngineError('Ambiguous initial State Engine hard links; operator recovery required'); + } + const { name, candidate, pid } = matches[0]; + // A still-running initializer may be about to remove its own staging + // link. Never unlink it on that process's behalf. + let ownerDead = false; + try { process.kill(pid, 0); } + catch (error) { + if (error.code === 'ESRCH') ownerDead = true; + else throw new StateEngineError('Cannot establish bootstrap staging owner termination'); + } + if (!ownerDead) { + throw new StateEngineError('Initial State Engine publication is still owned by a live process'); + } + + const expected = field === 'schema' ? stablePretty(schemaDocument()) + : field === 'snapshot' ? stablePretty(emptySnapshot()) + : stablePretty(buildStateIndex(emptySnapshot())); + if (fs.readFileSync(canonical, 'utf8') !== expected + || fs.readFileSync(candidate, 'utf8') !== expected) { + throw new StateEngineError('Initial State Engine staging content is not an exact bootstrap image'); + } + + const dirFd = fs.openSync(paths.stateRoot, 'r'); + try { + const pinned = fs.fstatSync(dirFd, { bigint: true }); + const observed = fs.lstatSync(paths.stateRoot, { bigint: true }); + const current = fs.lstatSync(canonical, { bigint: true }); + const staging = fs.lstatSync(candidate, { bigint: true }); + if (!pinned.isDirectory() || !observed.isDirectory() || observed.isSymbolicLink() + || pinned.dev !== observed.dev || pinned.ino !== observed.ino + || !current.isFile() || current.isSymbolicLink() + || current.dev !== stat.dev || current.ino !== stat.ino || current.nlink !== 2n + || !staging.isFile() || staging.isSymbolicLink() + || staging.dev !== stat.dev || staging.ino !== stat.ino || staging.nlink !== 2n) { + throw new StateEngineError('Initial State Engine staging identity changed before recovery'); + } + const unlinkTarget = process.platform === 'linux' + ? path.join('/proc/self/fd/' + dirFd, name) : candidate; + fs.unlinkSync(unlinkTarget); + flushStateDirectory(paths.stateRoot, dirFd); + } finally { + fs.closeSync(dirFd); + } + } +} + +/** + * Persist directory namespace updates before journal acknowledgement. + * POSIX uses the pinned directory FD; Windows must open a write-capable + * directory FD for fsync/FlushFileBuffers. Unavailable barriers fail closed. + */ +function flushStateDirectory(directory, pinnedDirFd = null) { + if (process.platform !== 'win32' && pinnedDirFd !== null) { + fs.fsyncSync(pinnedDirFd); + return; + } + let fd; + try { + fd = fs.openSync(directory, process.platform === 'win32' ? 'r+' : 'r'); + const stat = fs.fstatSync(fd); + if (!stat.isDirectory()) { + throw new StateEngineError('State Engine directory flush target is not a directory'); + } + if (pinnedDirFd !== null) { + const pinned = fs.fstatSync(pinnedDirFd); + if (!pinned.isDirectory() || pinned.dev !== stat.dev || pinned.ino !== stat.ino) { + throw new StateEngineError('State Engine directory changed before durability barrier'); + } + } + fs.fsyncSync(fd); + } catch (error) { + throw new StateEngineError('State Engine directory durability barrier failed; refusing acknowledgement', { + code: 'STATE_DIRECTORY_FLUSH_FAILED', + directory, + cause: error.code || error.message, + }); + } finally { + if (fd !== undefined) fs.closeSync(fd); + } +} + +function fsyncParentDirectory(directory) { + flushStateDirectory(directory); +} + +function pinnedUnlinkAndSync(filePath, pinnedDirFd = null) { + const directory = path.dirname(filePath); + const file = path.basename(filePath); + const ownsDirFd = !pinnedDirFd; + const dirFd = pinnedDirFd ?? fs.openSync(directory, 'r'); + try { + const pinned = fs.fstatSync(dirFd); + const observed = fs.lstatSync(directory); + if (!pinned.isDirectory() || !observed.isDirectory() || observed.isSymbolicLink() || + pinned.dev !== observed.dev || pinned.ino !== observed.ino) { + throw new StateEngineError('State Engine directory changed before journal cleanup'); + } + const rootDir = path.dirname(path.dirname(directory)); + assertStateRootRealpathSafe({ root: rootDir, stateRoot: directory }); + const target = process.platform === 'linux' ? path.join('/proc/self/fd/' + dirFd, file) : filePath; + const preStat = fs.lstatSync(target); + if (preStat.isSymbolicLink() || !preStat.isFile() || preStat.nlink > 1) { + throw new StateEngineError('Target journal file is not a regular unlinked file'); + } + // Trust model (T04, trusted/exclusive local runtime): two directory entries + // cannot be checked-and-unlinked atomically against a hostile same-UID + // process, so no ledger-binding check is attempted here. Ledger authority is + // enforced instead on every load, which rejects any snapshot/index that is + // inconsistent with canonical history (see loadStateSnapshot). + // Recheck directory immediately before unlink on platforms without descriptor-relative paths + const latest = fs.lstatSync(directory); + if (!latest.isDirectory() || latest.isSymbolicLink() || + latest.dev !== pinned.dev || latest.ino !== pinned.ino) { + throw new StateEngineError('State Engine directory was replaced before journal cleanup'); + } + fs.unlinkSync(target); + flushStateDirectory(directory, dirFd); + } finally { + if (ownsDirFd) fs.closeSync(dirFd); + } +} + +function atomicWrite(filePath, content, pinnedDirFd = null) { + const directory = path.dirname(filePath); + fs.mkdirSync(directory, { recursive: true }); + // Pin the already-validated State Engine directory before creating a + // journal, snapshot or index. Linux exposes descriptor-relative paths via + // /proc/self/fd; renames there cannot follow a subsequently swapped + // workspace directory symlink into an external target. + const ownsDirFd = !pinnedDirFd; + const dirFd = pinnedDirFd ?? fs.openSync(directory, 'r'); + const file = path.basename(filePath); + const pinnedDir = process.platform === 'linux' ? '/proc/self/fd/' + dirFd : directory; + const tempPath = path.join(pinnedDir, file + '.tmp-' + process.pid + '-' + crypto.randomUUID()); + const destination = path.join(pinnedDir, file); + let published = false; + try { + const pinned = fs.fstatSync(dirFd); + const observed = fs.lstatSync(directory); + if (!pinned.isDirectory() || !observed.isDirectory() || observed.isSymbolicLink() || + pinned.dev !== observed.dev || pinned.ino !== observed.ino) { + throw new StateEngineError('State Engine directory changed during durable write preparation'); + } + const rootDir = path.dirname(path.dirname(directory)); + assertStateRootRealpathSafe({ root: rootDir, stateRoot: directory }); + const fd = fs.openSync(tempPath, 'wx', 0o600); + try { + fs.writeFileSync(fd, content, 'utf8'); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + // On non-Linux platforms without handle-relative filesystem primitives, + // revalidate the directory identity immediately before publication. + // Hostile same-UID concurrent processes remain unsupported outside + // a trusted offline maintenance environment on those hosts. + const latest = fs.lstatSync(directory); + if (!latest.isDirectory() || latest.isSymbolicLink() || + latest.dev !== pinned.dev || latest.ino !== pinned.ino) { + throw new StateEngineError('State Engine directory was replaced before durable publication'); + } + fs.renameSync(tempPath, destination); + published = true; + flushStateDirectory(directory, dirFd); + return filePath; + } finally { + if (!published) { + try { fs.unlinkSync(tempPath); } catch {} + } + if (ownsDirFd) fs.closeSync(dirFd); + } +} + +// Windows NTFS directory flushes and Defender I/O can extend a valid +// four-process writer queue beyond 12s under concurrent full CI. Keep a +// bounded longer wait on Windows; never reclaim a live owner or bypass locks. +function acquireStateLock(rootDir, timeoutMs = process.platform === 'win32' ? 20_000 : 12_000) { + const paths = statePaths(rootDir); + fs.mkdirSync(paths.stateRoot, { recursive: true }); + assertStateRootRealpathSafe(paths); + const owner = 'state-lock-' + crypto.randomUUID(); + const started = Date.now(); + + while (Date.now() - started < timeoutMs) { + try { + const fd = fs.openSync(paths.lock, 'wx'); + let createdIdentity = null; + let initializationFailure = null; + try { + createdIdentity = fs.fstatSync(fd, { bigint: true }); + fs.writeFileSync(fd, JSON.stringify({ + owner, + pid: process.pid, + hostname: os.hostname(), + acquiredAt: new Date().toISOString(), + }), 'utf8'); + fs.fsyncSync(fd); + } catch (error) { + initializationFailure = error; + } + try { + fs.closeSync(fd); + } catch (error) { + initializationFailure ??= error; + } + if (initializationFailure) { + // The exclusive lock name already exists, but its owner metadata may + // be absent or non-durable. Do not strand an empty/unowned lock after + // ENOSPC, EIO or close failure. Remove only the same single-link inode + // that this process created, never a replacement installed by another + // process. A failed cleanup remains an explicit offline-recovery fault. + try { + const current = fs.lstatSync(paths.lock, { bigint: true }); + if (!createdIdentity || !current.isFile() || current.isSymbolicLink() || + current.nlink !== 1n || current.dev !== createdIdentity.dev || + current.ino !== createdIdentity.ino) { + throw new StateEngineError('Newly created lock identity changed before failed-initialization cleanup'); + } + fs.unlinkSync(paths.lock); + fsyncParentDirectory(paths.stateRoot); + } catch (cleanupError) { + throw new StateEngineError( + 'State Engine lock initialization failed and cleanup was not durably verified; offline recovery required', + { code: 'STATE_LOCK_INIT_CLEANUP_FAILED', + cause: cleanupError.code || cleanupError.message, + initializationCause: initializationFailure.code || initializationFailure.message }, + ); + } + throw initializationFailure; + } + return { path: paths.lock, owner }; + } catch (error) { + if (error.code !== 'EEXIST') { + // Windows can briefly deny create/open while another process closes or + // removes this exact lock file. Retry a bounded transient sharing + // violation; never interpret it as permission to steal the lock. + if (process.platform !== 'win32' || !['EPERM', 'EACCES'].includes(error.code)) { + throw new StateEngineError('Unable to acquire State Engine lock: ' + error.message); + } + waitStateLock(25); + continue; + } + let stat; + try { + stat = fs.statSync(paths.lock); + } catch (readError) { + if (readError.code === 'ENOENT') continue; + throw new StateEngineError('Cannot inspect existing State Engine lock: ' + readError.message); + } + if (Date.now() - stat.mtimeMs > 15_000) { + let prior; + try { + prior = JSON.parse(fs.readFileSync(paths.lock, 'utf8')); + } catch { + // Corrupt or incomplete lock metadata must never authorize lock stealing. + prior = null; + } + // A live local PID owns the lock regardless of its age. Remote and legacy + // owners are treated as unknown and cannot be stolen automatically. + if (prior && prior.hostname === os.hostname() && Number.isSafeInteger(prior.pid) && prior.pid > 0) { + let alive = true; + try { + process.kill(prior.pid, 0); + } catch (probeError) { + if (probeError.code === 'ESRCH') alive = false; + } + if (!alive) { + // Pathname compare-then-unlink is unsafe: another reclaimer may + // install a fresh live lock between the check and unlink. mkdir + // gives each local stale-lock reclaimer exclusive ownership of a + // recovery guard, so other reclaimers cannot race its unlink. + const reclaimGuard = paths.lock + '.reclaim'; + let guardOwned = false; + let guardMarker = null; + try { + try { + fs.mkdirSync(reclaimGuard, { mode: 0o700 }); + guardOwned = true; + guardMarker = path.join(reclaimGuard, 'owner.json'); + const guardFd = fs.openSync(guardMarker, 'wx', 0o600); + try { + fs.writeFileSync(guardFd, JSON.stringify({ + pid: process.pid, + hostname: os.hostname(), + owner, + acquiredAt: new Date().toISOString(), + })); + fs.fsyncSync(guardFd); + } finally { + fs.closeSync(guardFd); + } + } catch (guardError) { + if (guardError.code !== 'EEXIST') throw guardError; + } + if (guardOwned) { + let latest; + try { + latest = fs.statSync(paths.lock); + } catch (raceError) { + if (raceError.code !== 'ENOENT') throw raceError; + } + if (latest && latest.ino === stat.ino && latest.mtimeMs === stat.mtimeMs && latest.size === stat.size) { + let current; + try { + current = JSON.parse(fs.readFileSync(paths.lock, 'utf8')); + } catch (readError) { + if (readError.code !== 'ENOENT') throw readError; + } + if (current?.owner === prior.owner && current?.pid === prior.pid && current?.hostname === prior.hostname) { + // Verify ownership is still dead inside the reclaim guard. + let confirmedDead = false; + try { + process.kill(current.pid, 0); + } catch (probeError) { + confirmedDead = probeError.code === 'ESRCH'; + } + if (confirmedDead) { + try { + fs.unlinkSync(paths.lock); + } catch (removeError) { + if (removeError.code !== 'ENOENT') throw removeError; + } + } + } + } + } + } finally { + if (guardOwned) { + if (guardMarker && fs.existsSync(guardMarker)) fs.unlinkSync(guardMarker); + fs.rmdirSync(reclaimGuard); + } + } + if (guardOwned) continue; + } + } + } + // Use bounded, synchronous waiting so Node >=18 and current callers remain compatible. + waitStateLock(25); + } + } + throw new StateEngineError('State Engine lock acquisition timed out'); +} + +/** + * Explicit OFFLINE maintenance operation. Never reclaim an orphaned guard in + * the normal commit path: proving the reclaimer is gone requires inspecting + * owner metadata and ruling out an active writer. The caller must take the + * project out of service before acknowledging recovery. + */ +export function recoverAbandonedStateLock({ rootDir = process.cwd(), confirmOffline = false } = {}) { + if (confirmOffline !== true) { + throw new StateEngineError('Explicit offline confirmation is required for abandoned-lock recovery'); + } + const paths = statePaths(rootDir); + const guard = paths.lock + '.reclaim'; + if (!fs.existsSync(guard)) return Object.freeze({ recovered: false, reason: 'no guard' }); + const stat = fs.lstatSync(guard); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new StateEngineError('Abandoned lock guard is not a regular directory'); + } + if (Date.now() - stat.mtimeMs < 60_000) { + throw new StateEngineError('Refusing to reclaim a recently active State Engine guard'); + } + const marker = path.join(guard, 'owner.json'); + if (fs.existsSync(marker)) { + const owner = JSON.parse(fs.readFileSync(marker, 'utf8')); + if (owner.hostname !== os.hostname() || !Number.isSafeInteger(owner.pid) || owner.pid <= 0) { + throw new StateEngineError('Cannot establish local guard ownership for offline recovery'); + } + try { + process.kill(owner.pid, 0); + throw new StateEngineError('Refusing to remove a live State Engine recovery guard'); + } catch (probe) { + if (probe.code !== 'ESRCH') throw probe; + } + } + if (fs.existsSync(paths.lock)) { + const lock = JSON.parse(fs.readFileSync(paths.lock, 'utf8')); + if (lock.hostname !== os.hostname() || !Number.isSafeInteger(lock.pid) || lock.pid <= 0) { + throw new StateEngineError('Offline guard recovery requires a provably local dead state lock'); + } + try { + process.kill(lock.pid, 0); + throw new StateEngineError('Refusing to recover a guard while state lock owner is alive'); + } catch (probe) { + if (probe.code !== 'ESRCH') throw probe; + } + } + if (fs.existsSync(marker)) fs.unlinkSync(marker); + fs.rmdirSync(guard); + return Object.freeze({ recovered: true, mode: 'confirmed-offline' }); +} + +function releaseStateLock(lock) { + if (!lock?.path) return; + try { + const current = JSON.parse(fs.readFileSync(lock.path, 'utf8')); + if (current?.owner !== lock.owner) { + throw new StateEngineError('State Engine lock ownership changed before release'); + } + fs.unlinkSync(lock.path); + } catch (error) { + // A completed canonical append may already be durable. Never report + // unqualified success if its lock was not demonstrably released, and never + // silently authorize a blind retry that could duplicate committed work. + // Missing/replaced/corrupt locks and I/O failures require offline review. + throw new StateEngineError( + 'State Engine lock release could not be verified; canonical commit may already be durable; offline recovery required', + { code: 'STATE_LOCK_RELEASE_FAILED', cause: error.code || error.message }, + ); + } +} + +function schemaDocument() { + return Object.freeze({ + schemaVersion: STATE_ENGINE_SCHEMA_VERSION, + authority: { + canonicalHistory: STATE_EVENTS_FILE, + materializedSnapshot: STATE_SNAPSHOT_FILE, + disposableIndex: STATE_INDEX_FILE, + }, + contracts: { + event: STATE_EVENT_SCHEMA_VERSION, + snapshot: STATE_SNAPSHOT_SCHEMA_VERSION, + index: STATE_INDEX_SCHEMA_VERSION, + }, + invariants: [ + 'events.jsonl is canonical runtime state history', + 'snapshot.json is derived from events.jsonl', + 'index.db is disposable and rebuildable', + 'event sequence is monotonic', + 'event hashes form a single integrity chain', + ], + }); +} + +/** + * Create an initial layout entry without ever replacing a concurrently + * published canonical file. Atomic rename of an empty bootstrap ledger is + * unsafe: an independent writer may commit between existsSync and rename. + * O_EXCL ('wx') makes the first creator win; an existing entry is never + * overwritten. Directory identity and durability checks match atomicWrite. + */ +function createInitialStateFile(filePath, content) { + const directory = path.dirname(filePath); + const dirFd = fs.openSync(directory, 'r'); + let temporary = null; + let fd; + try { + const pinned = fs.fstatSync(dirFd); + const observed = fs.lstatSync(directory); + if (!pinned.isDirectory() || !observed.isDirectory() || observed.isSymbolicLink() || + pinned.dev !== observed.dev || pinned.ino !== observed.ino) { + throw new StateEngineError('State Engine directory changed before exclusive initialization'); + } + const root = path.dirname(path.dirname(directory)); + assertStateRootRealpathSafe({ root, stateRoot: directory }); + const pinnedDirectory = process.platform === 'linux' ? '/proc/self/fd/' + dirFd : directory; + const destination = path.join(pinnedDirectory, path.basename(filePath)); + + if (content === '') { + // An empty ledger is already a complete valid file at exclusive create. + // O_EXCL ensures a competing writer's committed ledger is never replaced. + try { + fd = fs.openSync(destination, 'wx', 0o600); + } catch (error) { + if (error.code === 'EEXIST') return false; + throw error; + } + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + } else { + // Publishing a non-empty schema/snapshot/index with O_EXCL exposes an + // incomplete file to concurrent readers and leaves invalid state if the + // process dies before fsync. Prepare and fsync privately, then create + // the canonical name with an atomic, non-replacing same-directory link. + // No rename-overwrite is allowed; on unsupported filesystems fail closed. + temporary = path.join(pinnedDirectory, + path.basename(filePath) + '.tmp-' + process.pid + '-' + crypto.randomUUID()); + fd = fs.openSync(temporary, 'wx', 0o600); + fs.writeFileSync(fd, content, 'utf8'); + fs.fsyncSync(fd); + fs.closeSync(fd); + fd = undefined; + const beforePublish = fs.lstatSync(directory); + if (!beforePublish.isDirectory() || beforePublish.isSymbolicLink() || + beforePublish.dev !== pinned.dev || beforePublish.ino !== pinned.ino) { + throw new StateEngineError('State Engine directory changed before initial publication'); + } + try { + fs.linkSync(temporary, destination); + } catch (error) { + if (error.code === 'EEXIST') return false; + throw error; + } + // Remove the staging name immediately, before any observer can treat + // a temporary second hard link as an enduring canonical-file hazard. + fs.unlinkSync(temporary); + temporary = null; + } + const latest = fs.lstatSync(directory); + if (!latest.isDirectory() || latest.isSymbolicLink() || + latest.dev !== pinned.dev || latest.ino !== pinned.ino) { + throw new StateEngineError('State Engine directory changed during exclusive initialization'); + } + flushStateDirectory(directory, dirFd); + return true; + } finally { + if (fd !== undefined) fs.closeSync(fd); + if (temporary !== null) { + try { fs.unlinkSync(temporary); } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + } + fs.closeSync(dirFd); + } +} + +/** + * The canonical ledger must never be reinitialized after a committed history + * has left a snapshot or index witness. An interrupted first initializer may + * have only its schema; published derived state always follows ledger creation. + * Loss of the ledger without a pending journal is still a recovery fault. + */ +function assertMissingCanonicalLedgerHasNoStateWitness(paths) { + if (fs.existsSync(paths.pending)) { + throw new StateEngineError( + 'State Engine events.jsonl is missing while pending-commit.json is present; state integrity compromised', + ); + } + if (fs.existsSync(paths.snapshot) || fs.existsSync(paths.index)) { + throw new StateEngineError( + 'State Engine canonical events.jsonl is missing while snapshot/index history witness remains; offline recovery required', + { code: 'STATE_CANONICAL_LEDGER_MISSING' }, + ); + } +} + +export function ensureStateEngineLayout(rootDir = process.cwd()) { + const paths = statePaths(rootDir); + fs.mkdirSync(paths.stateRoot, { recursive: true }); + assertStateRootRealpathSafe(paths); + + const expectedSchema = stablePretty(schemaDocument()); + if (!fs.existsSync(paths.schema)) createInitialStateFile(paths.schema, expectedSchema); + if (fs.readFileSync(paths.schema, 'utf8') !== expectedSchema) { + throw new StateEngineError('State Engine schema.json does not match the runtime schema contract'); + } + + if (!fs.existsSync(paths.events)) { + assertMissingCanonicalLedgerHasNoStateWitness(paths); + createInitialStateFile(paths.events, ''); + } + if (!fs.existsSync(paths.snapshot)) { + createInitialStateFile(paths.snapshot, stablePretty(emptySnapshot())); + } + if (!fs.existsSync(paths.index)) { + createInitialStateFile(paths.index, stablePretty(buildStateIndex(emptySnapshot()))); + } + assertStateFilesNotLinks(paths); + return paths; +} + +export function isStateEngineV2Active(rootDir = process.cwd(), { recoverIfNeeded = true } = {}) { + const paths = statePaths(rootDir, { recoverBootstrapIfNeeded: recoverIfNeeded }); + if (!fs.existsSync(paths.stateRoot)) return false; + + const schemaExists = fs.existsSync(paths.schema); + const eventsExist = fs.existsSync(paths.events); + if (!schemaExists && !eventsExist) return false; + if (!schemaExists || !eventsExist) { + throw new StateEngineError('State Engine V2 layout is incomplete'); + } + + const expectedSchema = stablePretty(schemaDocument()); + const actualSchema = fs.readFileSync(paths.schema, 'utf8'); + if (actualSchema !== expectedSchema) { + throw new StateEngineError('State Engine schema.json does not match the runtime schema contract'); + } + return true; +} + +function emptySnapshot() { + return { + schemaVersion: STATE_SNAPSHOT_SCHEMA_VERSION, + engineSchemaVersion: STATE_ENGINE_SCHEMA_VERSION, + lastEventSequence: 0, + lastEventHash: null, + entities: {}, + migrations: [], + }; +} + +function eventHashPayload(event) { + const { eventHash, ...payload } = event; + return payload; +} + +function computeEventId(base) { + const digest = crypto.createHash('sha256').update(stableJson(base)).digest('hex').slice(0, 20); + return `evt-${String(base.sequence).padStart(10, '0')}-${digest}`; +} + +function computeEventHash(event) { + return sha256(stableJson(eventHashPayload(event))); +} + +export function validateStateEvent(event, previous = null) { + if (!plainObject(event)) throw new StateEngineError('State event must be an object'); + if (event.schemaVersion !== STATE_EVENT_SCHEMA_VERSION) { + throw new StateEngineError(`Unsupported event schemaVersion: ${event.schemaVersion}`); + } + if (!Number.isInteger(event.sequence) || event.sequence < 1) throw new StateEngineError('Event sequence must be a positive integer'); + if (typeof event.eventId !== 'string' || !/^evt-\d{10}-[a-f0-9]{20}$/.test(event.eventId)) { + throw new StateEngineError('Event eventId is invalid'); + } + timestamp(event.timestamp, 'event.timestamp'); + requireText(event.actorClass, 'event.actorClass'); + eventType(event.eventType); + + if (event.entityType !== null) entityType(event.entityType); + if (event.entityId !== null) entityId(event.entityId); + for (const field of ['lifecycleId', 'taskId', 'contractId', 'runId']) optionalText(event[field], field); + if (!plainObject(event.payload)) throw new StateEngineError('Event payload must be an object'); + + if (previous === null) { + if (event.sequence !== 1) throw new StateEngineError('First state event sequence must be 1'); + if (event.previousEventHash !== null) throw new StateEngineError('First state event previousEventHash must be null'); + } else { + if (event.sequence !== previous.sequence + 1) throw new StateEngineError('Event sequence is not contiguous'); + if (event.previousEventHash !== previous.eventHash) throw new StateEngineError('Event previousEventHash does not match chain'); + } + + if (typeof event.eventHash !== 'string' || !SHA256_PATTERN.test(event.eventHash)) { + throw new StateEngineError('Event eventHash is invalid'); + } + if (event.eventHash !== computeEventHash(event)) { + throw new StateEngineError(`Event hash mismatch at sequence ${event.sequence}`); + } + + const base = { ...eventHashPayload(event) }; + delete base.eventId; + if (event.eventId !== computeEventId(base)) { + throw new StateEngineError(`Event ID mismatch at sequence ${event.sequence}`); + } + + return true; +} + +function parseLedger(content) { + if (!content) return []; + const lines = content.split(/\r?\n/); + if (lines.at(-1) === '') lines.pop(); + const events = []; + let previous = null; + + for (let i = 0; i < lines.length; i += 1) { + if (!lines[i].trim()) throw new StateEngineError(`Canonical state ledger contains a blank record at line ${i + 1}`); + let event; + try { + event = JSON.parse(lines[i]); + } catch (error) { + throw new StateEngineError(`Canonical state ledger JSON is invalid at line ${i + 1}: ${error.message}`); + } + validateStateEvent(event, previous); + events.push(event); + previous = event; + } + return events; +} + +function acknowledgedSnapshotWitness(paths) { + let raw; + try { + raw = fs.readFileSync(paths.snapshot, 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') return null; + // Unreadable materialized evidence may witness an acknowledged later + // event. Never treat permission/I/O failure as an absent disposable view + // and truncate the canonical ledger without inspecting that witness. + throw new StateEngineError( + 'Cannot inspect materialized snapshot witness before ledger tail repair: ' + + (error.code || error.message), + ); + } + try { + const snapshot = JSON.parse(raw); + validateStateSnapshot(snapshot); + return snapshot; + } catch { + // Malformed derived JSON/schema is disposable; filesystem read errors + // above are not and must retain the original torn canonical evidence. + return null; + } +} + +/** + * A derived snapshot or index is disposable only when it does not witness a + * later or different acknowledged canonical history. Compare against the + * ledger prefix BEFORE creating a new pending journal or rewriting derived + * files; otherwise a valid truncated prefix could be silently re-acknowledged. + * Read failures are not evidence of absence. Malformed derived JSON remains + * rebuildable, as it cannot establish a trustworthy history witness. + */ +function assertDerivedHistoryWitnesses(paths, canonicalEvents, fields = ['snapshot', 'index']) { + if (!Array.isArray(canonicalEvents)) { + throw new StateEngineError('Canonical events are required for source-bound derived witness validation'); + } + for (const field of fields) { + let bytes; + try { + bytes = fs.readFileSync(paths[field], 'utf8'); + } catch (error) { + if (error.code === 'ENOENT') continue; + throw new StateEngineError( + 'Cannot inspect acknowledged ' + field + ' witness before canonical mutation: ' + + (error.code || error.message), + { code: 'STATE_DERIVED_WITNESS_READ_FAILED', field, cause: error.code || error.message }, + ); + } + let witness; + try { + witness = JSON.parse(bytes); + if (field === 'snapshot') validateStateSnapshot(witness); + else validateStateIndex(witness); + } catch { + // Malformed derived JSON/schema is rebuildable; unreadable evidence is not. + continue; + } + const sequence = field === 'snapshot' ? witness.lastEventSequence : witness.sourceSequence; + const hash = field === 'snapshot' ? witness.lastEventHash : witness.sourceHash; + if (sequence > canonicalEvents.length) { + throw new StateEngineError( + 'Canonical state history appears truncated relative to the materialized ' + field + ' witness', + { code: 'STATE_CANONICAL_HISTORY_REGRESSION', field, + canonicalSequence: canonicalEvents.length, witnessedSequence: sequence }, + ); + } + // A witness may be older than the proposed ledger after a legitimate + // append, but only when it agrees with the hash at its OWN sequence. + // Comparing only the proposed final hash would miss a conflicting event + // followed by later valid events (T04-H66). + const prefixHash = sequence === 0 ? null : canonicalEvents[sequence - 1].eventHash; + if (hash !== prefixHash) { + throw new StateEngineError( + 'Canonical state history hash differs from the materialized ' + field + ' integrity witness', + { code: 'STATE_CANONICAL_HISTORY_HASH_CONFLICT', field, + witnessedSequence: sequence, witnessedHash: hash, canonicalPrefixHash: prefixHash }, + ); + } + } +} + +function repairUnwitnessedTrailingLedgerSuffixLocked(rootDir) { + const paths = ensureStateEngineLayout(rootDir); + if (fs.existsSync(paths.pending)) { + // withStateLock() recovers a valid pending commit before invoking this + // function. Seeing one here means recovery did not safely resolve it. + throw new StateEngineError('Pending commit remains unresolved during trailing-ledger recovery'); + } + + // Record the canonical inode before reading repair input. A path swapped + // after layout preflight must never become the target of truncation/append. + const initialEntry = fs.lstatSync(paths.events, { bigint: true }); + if (!initialEntry.isFile() || initialEntry.isSymbolicLink() || initialEntry.nlink !== 1n) { + throw new StateEngineError('Canonical ledger tail repair requires a single-link regular file'); + } + // Preserve the exact on-disk bytes. Decoding an incomplete multibyte + // trailing character inserts U+FFFD; re-encoding that string must never be + // used as the descriptor witness or to decide that raw bytes changed. + const expectedBytes = fs.readFileSync(paths.events); + const content = expectedBytes.toString('utf8'); + + function verifyCanonicalTailRepairFd(fd) { + const opened = fs.fstatSync(fd, { bigint: true }); + const entry = fs.lstatSync(paths.events, { bigint: true }); + if (!opened.isFile() || !entry.isFile() || entry.isSymbolicLink() || + opened.nlink !== 1n || entry.nlink !== 1n || + opened.dev !== entry.dev || opened.ino !== entry.ino || + opened.dev !== initialEntry.dev || opened.ino !== initialEntry.ino) { + throw new StateEngineError('Canonical ledger tail repair descriptor identity or hard-link integrity failed'); + } + return opened; + } + + function openVerifiedCanonicalTailRepairFd() { + const flags = fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0); + const fd = fs.openSync(paths.events, flags); + try { + const opened = verifyCanonicalTailRepairFd(fd); + if (opened.size !== BigInt(expectedBytes.length)) { + throw new StateEngineError('Canonical ledger tail repair bytes changed before descriptor open'); + } + const observed = Buffer.alloc(expectedBytes.length); + let offset = 0; + while (offset < observed.length) { + const count = fs.readSync(fd, observed, offset, observed.length - offset, offset); + if (count <= 0) { + throw new StateEngineError('Canonical ledger tail repair descriptor read was incomplete'); + } + offset += count; + } + if (!observed.equals(expectedBytes)) { + throw new StateEngineError('Canonical ledger tail repair descriptor contents changed before mutation'); + } + return fd; + } catch (error) { + fs.closeSync(fd); + throw error; + } + } + if (!content || content.endsWith('\n')) return parseLedger(content); + + const boundary = content.lastIndexOf('\n'); + const prefixContent = boundary === -1 ? '' : content.slice(0, boundary + 1); + const tail = content.slice(boundary + 1); + const prefixEvents = parseLedger(prefixContent); + const previous = prefixEvents.at(-1) ?? null; + + // Only a JSON syntax error can authorize torn-suffix truncation. A + // successfully parsed event must either validate and complete its newline, + // or fail closed on any write, fsync, descriptor or integrity error. + // Never classify ENOSPC/EIO during newline completion as malformed JSON: + // doing so could truncate an otherwise valid canonical event. + let parsedTail; + let completeJson = false; + try { + parsedTail = JSON.parse(tail); + completeJson = true; + } catch (error) { + if (!(error instanceof SyntaxError)) throw error; + } + if (completeJson) { + validateStateEvent(parsedTail, previous); + const fd = openVerifiedCanonicalTailRepairFd(); + try { + const stat = fs.fstatSync(fd); + if (!stat.isFile()) throw new StateEngineError('Canonical ledger repair target is not a regular file'); + fs.writeSync(fd, Buffer.from([0x0a]), 0, 1, Buffer.byteLength(content, 'utf8')); + fs.fsyncSync(fd); + verifyCanonicalTailRepairFd(fd); + } finally { + fs.closeSync(fd); + } + return [...prefixEvents, parsedTail]; + } + + // The final suffix is not valid JSON. It may be repaired only when no + // already-acknowledged snapshot claims a later canonical event. This keeps + // a missing Windows pending-journal directory entry from making a torn + // unacknowledged append unrecoverable, while preventing rollback of + // previously materialized history. + const witness = acknowledgedSnapshotWitness(paths); + if (witness && witness.lastEventSequence > prefixEvents.length) { + throw new StateEngineError( + 'Refusing to truncate trailing canonical bytes witnessed by the materialized snapshot', + { + code: 'ACKNOWLEDGED_LEDGER_TAIL_CORRUPT', + snapshotSequence: witness.lastEventSequence, + recoverablePrefixSequence: prefixEvents.length, + }, + ); + } + if (witness && witness.lastEventSequence === prefixEvents.length) { + const prefixHash = prefixEvents.at(-1)?.eventHash ?? null; + if (witness.lastEventHash !== prefixHash) { + throw new StateEngineError('Materialized snapshot hash disagrees with recoverable canonical prefix'); + } + } + if (witness && witness.lastEventSequence < prefixEvents.length) { + const witnessedHash = witness.lastEventSequence === 0 + ? null : prefixEvents[witness.lastEventSequence - 1].eventHash; + if (witness.lastEventHash !== witnessedHash) { + throw new StateEngineError('Materialized snapshot hash disagrees with its recoverable canonical prefix'); + } + } + + // An index can be the sole surviving acknowledged-history witness when the + // snapshot is absent or malformed. It must also block destructive truncation. + assertDerivedHistoryWitnesses(paths, prefixEvents, ['index']); + const fd = openVerifiedCanonicalTailRepairFd(); + try { + fs.ftruncateSync(fd, Buffer.byteLength(prefixContent, 'utf8')); + fs.fsyncSync(fd); + verifyCanonicalTailRepairFd(fd); + } finally { + fs.closeSync(fd); + } + return prefixEvents; +} + +export function loadCanonicalEvents(rootDir = process.cwd(), { recoverIfNeeded = true } = {}) { + const paths = statePaths(rootDir, { recoverBootstrapIfNeeded: recoverIfNeeded }); + if (!fs.existsSync(paths.events)) { + assertMissingCanonicalLedgerHasNoStateWitness(paths); + return []; + } + if (fs.existsSync(paths.pending)) { + if (!recoverIfNeeded) { + throw new StateEngineError('Pending State Engine commit requires recovery; read-only lookup cannot retire recovery evidence'); + } + return withStateLock(rootDir, () => parseLedger(fs.readFileSync(paths.events, 'utf8'))); + } + + const content = fs.readFileSync(paths.events, 'utf8'); + if (fs.existsSync(paths.pending)) { + if (!recoverIfNeeded) { + throw new StateEngineError('Pending State Engine commit requires recovery; read-only lookup cannot retire recovery evidence'); + } + return withStateLock(rootDir, () => parseLedger(fs.readFileSync(paths.events, 'utf8'))); + } + if (!content || content.endsWith('\n')) return parseLedger(content); + if (!recoverIfNeeded) { + throw new StateEngineError('Canonical ledger has an unterminated tail requiring recovery; read-only lookup cannot repair it'); + } + + // Only a non-terminated final record is eligible for automatic tail + // recovery. Mid-ledger corruption and hash-chain failures remain fail-closed. + return withStateLock(rootDir, () => repairUnwitnessedTrailingLedgerSuffixLocked(rootDir)); +} + +function normalizePathSegments(value) { + if (!Array.isArray(value) || value.some((segment) => typeof segment !== 'string')) { + throw new StateEngineError('Patch operation path must be a string array'); + } + return value; +} + +function cloneSerializable(value, label) { + try { + const cloned = structuredClone(value); + const encoded = JSON.stringify(cloned); + // JSON.stringify silently discards Map/Set contents and undefined object + // fields, and rewrites Date/non-finite numbers. A successful stringify + // alone cannot establish that an acknowledged event is replayable without + // semantic data loss. Reject before publishing any canonical bytes. + if (typeof encoded !== 'string' || !isDeepStrictEqual(cloned, JSON.parse(encoded))) { + throw new StateEngineError(`${label} must be lossless JSON-serializable data`); + } + return cloned; + } catch { + throw new StateEngineError(`${label} must be lossless JSON-serializable data`); + } +} + +function diffValues(before, after, pathSegments = []) { + if (stableJson(before) === stableJson(after)) return []; + + if (plainObject(before) && plainObject(after)) { + const operations = []; + const keys = [...new Set([...Object.keys(before), ...Object.keys(after)])].sort(); + for (const key of keys) { + const childPath = [...pathSegments, key]; + if (!Object.hasOwn(after, key)) { + operations.push({ op: 'delete', path: childPath }); + } else if (!Object.hasOwn(before, key)) { + operations.push({ op: 'set', path: childPath, value: cloneSerializable(after[key], 'Patch value') }); + } else { + operations.push(...diffValues(before[key], after[key], childPath)); + } + } + return operations; + } + + return [{ op: 'set', path: pathSegments, value: cloneSerializable(after, 'Patch value') }]; +} + +function defineOwnPatchValue(target, key, value) { + // Literal JSON keys such as "__proto__" must remain own data properties. + // Assignment can invoke inherited setters and mutate a process prototype. + Object.defineProperty(target, key, { + value, enumerable: true, writable: true, configurable: true, + }); +} + +function setAtPath(target, segments, value) { + if (segments.length === 0) return cloneSerializable(value, 'Patch root value'); + let cursor = target; + for (let i = 0; i < segments.length - 1; i += 1) { + const key = segments[i]; + if (!Object.hasOwn(cursor, key) || !plainObject(cursor[key])) { + defineOwnPatchValue(cursor, key, {}); + } + cursor = cursor[key]; + } + defineOwnPatchValue(cursor, segments.at(-1), cloneSerializable(value, 'Patch value')); + return target; +} + +function deleteAtPath(target, segments) { + if (segments.length === 0) throw new StateEngineError('Cannot delete the entity-state root'); + let cursor = target; + for (let i = 0; i < segments.length - 1; i += 1) { + const key = segments[i]; + if (!Object.hasOwn(cursor, key) || !plainObject(cursor[key])) return target; + cursor = cursor[key]; + } + if (Object.hasOwn(cursor, segments.at(-1))) delete cursor[segments.at(-1)]; + return target; +} + +function applyOperations(previousState, operations) { + if (!Array.isArray(operations)) throw new StateEngineError('State event operations must be an array'); + let next = cloneSerializable(previousState, 'Previous state'); + for (const operation of operations) { + if (!plainObject(operation)) throw new StateEngineError('Patch operation must be an object'); + const segments = normalizePathSegments(operation.path); + if (operation.op === 'set') { + next = setAtPath(next, segments, operation.value); + } else if (operation.op === 'delete') { + next = deleteAtPath(next, segments); + } else { + throw new StateEngineError(`Unsupported patch operation: ${operation.op}`); + } + } + return next; +} + +function ownValue(object, key) { + return object !== null && object !== undefined && Object.hasOwn(object, key) + ? object[key] : undefined; +} + +function ensureEntityBucket(snapshot, type) { + if (!Object.hasOwn(snapshot.entities, type)) defineOwnPatchValue(snapshot.entities, type, {}); + return snapshot.entities[type]; +} + +export function rebuildSnapshotFromEvents(events) { + if (!Array.isArray(events)) throw new StateEngineError('events must be an array'); + const snapshot = emptySnapshot(); + let previous = null; + + for (const event of events) { + validateStateEvent(event, previous); + + if (event.eventType === 'ENTITY_STATE_INITIALIZED') { + if (event.entityType === null || event.entityId === null) throw new StateEngineError('State initialization event requires entity identity'); + const bucket = ensureEntityBucket(snapshot, event.entityType); + if (Object.hasOwn(bucket, event.entityId)) throw new StateEngineError(`Entity is initialized more than once: ${event.entityType}/${event.entityId}`); + const state = cloneSerializable(event.payload.state, 'Initialized state'); + const revision = stateRevision(event.payload.stateRevision); + defineOwnPatchValue(bucket, event.entityId, { + stateRevision: revision, + state, + lastEventSequence: event.sequence, + lastEventHash: event.eventHash, + refs: { + lifecycleId: event.lifecycleId, + taskId: event.taskId, + contractId: event.contractId, + runId: event.runId, + }, + }); + } else if (event.eventType === 'ENTITY_STATE_UPDATED') { + if (event.entityType === null || event.entityId === null) throw new StateEngineError('State update event requires entity identity'); + const bucket = ensureEntityBucket(snapshot, event.entityType); + const current = ownValue(bucket, event.entityId); + if (!current) throw new StateEngineError(`State update references unknown entity: ${event.entityType}/${event.entityId}`); + const revision = stateRevision(event.payload.stateRevision); + if (revision < current.stateRevision) { + throw new StateEngineError(`Entity stateRevision regressed for ${event.entityType}/${event.entityId}`); + } + current.state = applyOperations(current.state, event.payload.operations); + current.stateRevision = revision; + current.lastEventSequence = event.sequence; + current.lastEventHash = event.eventHash; + current.refs = { + lifecycleId: event.lifecycleId ?? current.refs.lifecycleId, + taskId: event.taskId ?? current.refs.taskId, + contractId: event.contractId ?? current.refs.contractId, + runId: event.runId ?? current.refs.runId, + }; + } else if (event.eventType === 'MIGRATION_COMPLETED') { + const migrationId = requireText(event.payload.migrationId, 'migrationId'); + const sourceFingerprint = requireText(event.payload.sourceFingerprint, 'sourceFingerprint'); + if (!SHA256_PATTERN.test(sourceFingerprint)) throw new StateEngineError('Migration sourceFingerprint is invalid'); + if (!snapshot.migrations.some((migration) => migration.migrationId === migrationId)) { + snapshot.migrations.push({ + migrationId, + sourceFingerprint, + completedAt: event.timestamp, + entityCount: Number(event.payload.entityCount ?? 0), + semanticEquivalence: event.payload.semanticEquivalence === true, + }); + } + } + + snapshot.lastEventSequence = event.sequence; + snapshot.lastEventHash = event.eventHash; + previous = event; + } + + snapshot.migrations.sort((a, b) => a.migrationId.localeCompare(b.migrationId)); + return snapshot; +} + +export function validateStateSnapshot(snapshot) { + if (!plainObject(snapshot)) throw new StateEngineError('State snapshot must be an object'); + if (snapshot.schemaVersion !== STATE_SNAPSHOT_SCHEMA_VERSION) throw new StateEngineError('Unsupported snapshot schemaVersion'); + if (snapshot.engineSchemaVersion !== STATE_ENGINE_SCHEMA_VERSION) throw new StateEngineError('Snapshot engine schema mismatch'); + if (!Number.isInteger(snapshot.lastEventSequence) || snapshot.lastEventSequence < 0) throw new StateEngineError('Snapshot lastEventSequence is invalid'); + if (snapshot.lastEventHash !== null && !SHA256_PATTERN.test(snapshot.lastEventHash)) throw new StateEngineError('Snapshot lastEventHash is invalid'); + if (!plainObject(snapshot.entities)) throw new StateEngineError('Snapshot entities must be an object'); + if (!Array.isArray(snapshot.migrations)) throw new StateEngineError('Snapshot migrations must be an array'); + + for (const [type, bucket] of Object.entries(snapshot.entities)) { + entityType(type); + if (!plainObject(bucket)) throw new StateEngineError(`Snapshot entity bucket is invalid: ${type}`); + for (const [id, record] of Object.entries(bucket)) { + entityId(id); + if (!plainObject(record)) throw new StateEngineError('Snapshot entity record must be an object'); + stateRevision(record.stateRevision); + if (!Number.isInteger(record.lastEventSequence) || record.lastEventSequence < 1) throw new StateEngineError('Entity lastEventSequence is invalid'); + if (!SHA256_PATTERN.test(record.lastEventHash)) throw new StateEngineError('Entity lastEventHash is invalid'); + cloneSerializable(record.state, 'Entity state'); + } + } + return true; +} + +function indexArrayPush(map, key, value) { + if (!key) return; + if (!Object.hasOwn(map, key)) defineOwnPatchValue(map, key, []); + if (!map[key].includes(value)) map[key].push(value); +} + +export function buildStateIndex(snapshot) { + validateStateSnapshot(snapshot); + const records = []; + const byContract = {}; + const byTask = {}; + const byRun = {}; + const byLifecycle = {}; + + for (const [type, bucket] of Object.entries(snapshot.entities).sort(([a], [b]) => a.localeCompare(b))) { + for (const [id, record] of Object.entries(bucket).sort(([a], [b]) => a.localeCompare(b))) { + // Encode the tuple rather than concatenating: both identity fields may contain '::'. + // This disposable index must never let a filter match another entity's record. + const key = JSON.stringify([type, id]); + records.push({ + key, + entityType: type, + entityId: id, + stateRevision: record.stateRevision, + lastEventSequence: record.lastEventSequence, + lastEventHash: record.lastEventHash, + }); + indexArrayPush(byContract, record.refs?.contractId, key); + indexArrayPush(byTask, record.refs?.taskId, key); + indexArrayPush(byRun, record.refs?.runId, key); + indexArrayPush(byLifecycle, record.refs?.lifecycleId, key); + } + } + + for (const map of [byContract, byTask, byRun, byLifecycle]) { + for (const values of Object.values(map)) values.sort(); + } + + return { + schemaVersion: STATE_INDEX_SCHEMA_VERSION, + sourceSequence: snapshot.lastEventSequence, + sourceHash: snapshot.lastEventHash, + recordCount: records.length, + records, + byContract, + byTask, + byRun, + byLifecycle, + }; +} + +export function validateStateIndex(index, snapshot = null) { + if (!plainObject(index)) throw new StateEngineError('State index must be an object'); + if (index.schemaVersion !== STATE_INDEX_SCHEMA_VERSION) throw new StateEngineError('Unsupported index schemaVersion'); + if (!Number.isInteger(index.sourceSequence) || index.sourceSequence < 0) throw new StateEngineError('Index sourceSequence is invalid'); + if (index.sourceHash !== null && !SHA256_PATTERN.test(index.sourceHash)) throw new StateEngineError('Index sourceHash is invalid'); + if (!Array.isArray(index.records)) throw new StateEngineError('Index records must be an array'); + if (index.recordCount !== index.records.length) throw new StateEngineError('Index recordCount mismatch'); + for (const field of ['byContract', 'byTask', 'byRun', 'byLifecycle']) { + if (!plainObject(index[field])) throw new StateEngineError(`Index ${field} must be an object`); + } + if (snapshot) { + validateStateSnapshot(snapshot); + if (index.sourceSequence !== snapshot.lastEventSequence || index.sourceHash !== snapshot.lastEventHash) { + throw new StateEngineError('State index is stale relative to snapshot'); + } + } + return true; +} + +function persistDerived(snapshot, rootDir, pinnedDirFd = null) { + validateStateSnapshot(snapshot); + const paths = pinnedDirFd ? statePaths(rootDir) : ensureStateEngineLayout(rootDir); + const index = buildStateIndex(snapshot); + atomicWrite(paths.snapshot, stablePretty(snapshot), pinnedDirFd); + atomicWrite(paths.index, stablePretty(index), pinnedDirFd); + return { snapshot, index }; +} + +function repairDerivedLocked(rootDir) { + // Snapshot repair replays the *current* ledger under the writer lock, never a + // stale cached history from before another writer committed a later event. + const paths = ensureStateEngineLayout(rootDir); + const canonicalEvents = parseLedger(fs.readFileSync(paths.events, 'utf8')); + const canonical = rebuildSnapshotFromEvents(canonicalEvents); + assertDerivedHistoryWitnesses(paths, canonicalEvents); + persistDerived(canonical, rootDir); + return canonical; +} + +export function rebuildStateSnapshot(rootDir = process.cwd()) { + return withStateLock(rootDir, () => repairDerivedLocked(rootDir)); +} + +export function loadStateSnapshot(rootDir = process.cwd(), { rebuildIfNeeded = true } = {}) { + const paths = statePaths(rootDir, { recoverBootstrapIfNeeded: rebuildIfNeeded }); + if (!fs.existsSync(paths.events)) { + assertMissingCanonicalLedgerHasNoStateWitness(paths); + return null; + } + // An index can independently witness a later acknowledged event even when + // both events.jsonl and snapshot.json have regressed to an older prefix. + // Check it before returning any snapshot, including read-only projections. + // On a concurrent writer's publication, retry only the canonical read; + // a persistent mismatch remains a hard integrity failure, never a rebuild. + let canonicalEvents = loadCanonicalEvents(rootDir, { recoverIfNeeded: rebuildIfNeeded }); + try { + assertDerivedHistoryWitnesses(paths, canonicalEvents, ['index']); + } catch (error) { + if (!['STATE_CANONICAL_HISTORY_REGRESSION', 'STATE_CANONICAL_HISTORY_HASH_CONFLICT'] + .includes(error.details?.code)) throw error; + canonicalEvents = loadCanonicalEvents(rootDir, { recoverIfNeeded: rebuildIfNeeded }); + assertDerivedHistoryWitnesses(paths, canonicalEvents, ['index']); + } + const canonical = rebuildSnapshotFromEvents(canonicalEvents); + + if (!fs.existsSync(paths.snapshot)) { + if (!rebuildIfNeeded) return null; + return withStateLock(rootDir, () => repairDerivedLocked(rootDir)); + } + + let current; + try { + current = JSON.parse(fs.readFileSync(paths.snapshot, 'utf8')); + validateStateSnapshot(current); + } catch (error) { + if (!rebuildIfNeeded) throw error; + return withStateLock(rootDir, () => repairDerivedLocked(rootDir)); + } + + if (current.lastEventSequence > canonical.lastEventSequence) { + // A legitimate writer can advance the snapshot between a reader's ledger + // and snapshot reads. Check the latest canonical history before declaring + // truncation, without silencing a genuine witnessed history regression. + if (rebuildIfNeeded) { + const refreshed = rebuildSnapshotFromEvents(loadCanonicalEvents(rootDir)); + if (refreshed.lastEventSequence > current.lastEventSequence + || (refreshed.lastEventSequence === current.lastEventSequence + && refreshed.lastEventHash === current.lastEventHash)) { + return refreshed; + } + } + throw new StateEngineError( + 'Canonical state history appears truncated relative to the materialized snapshot', + { + snapshotSequence: current.lastEventSequence, + canonicalSequence: canonical.lastEventSequence, + snapshotHash: current.lastEventHash, + canonicalHash: canonical.lastEventHash, + }, + ); + } + if (current.lastEventSequence === canonical.lastEventSequence + && current.lastEventHash !== canonical.lastEventHash) { + throw new StateEngineError( + 'Canonical state history hash differs from the materialized snapshot integrity witness', + { + sequence: current.lastEventSequence, + snapshotHash: current.lastEventHash, + canonicalHash: canonical.lastEventHash, + }, + ); + } + if (stableJson(current) !== stableJson(canonical)) { + if (!rebuildIfNeeded) throw new StateEngineError('Materialized snapshot does not match canonical history'); + return withStateLock(rootDir, () => repairDerivedLocked(rootDir)); + } + return current; +} + +export function rebuildStateIndex(rootDir = process.cwd()) { + return withStateLock(rootDir, () => { + const paths = ensureStateEngineLayout(rootDir); + // A reader-triggered rebuild is still a filesystem mutation. Preserve an + // existing index's acknowledged-history witness before replacing it. + const canonicalEvents = loadCanonicalEvents(rootDir); + assertDerivedHistoryWitnesses(paths, canonicalEvents); + const snapshot = loadStateSnapshot(rootDir, { rebuildIfNeeded: true }) ?? emptySnapshot(); + const index = buildStateIndex(snapshot); + atomicWrite(paths.index, stablePretty(index)); + return index; + }); +} + +export function loadStateIndex(rootDir = process.cwd(), { rebuildIfNeeded = true } = {}) { + // Honor the caller's read-only boundary: index verification must not + // silently materialize a missing snapshot when rebuilding is disabled. + const loadedSnapshot = loadStateSnapshot(rootDir, { rebuildIfNeeded }); + if (!loadedSnapshot && !rebuildIfNeeded) return null; + const snapshot = loadedSnapshot ?? emptySnapshot(); + const paths = statePaths(rootDir); + if (!fs.existsSync(paths.index)) { + return rebuildIfNeeded ? rebuildStateIndex(rootDir) : null; + } + try { + const index = JSON.parse(fs.readFileSync(paths.index, 'utf8')); + validateStateIndex(index, snapshot); + // A matching sequence/hash only proves source freshness, not that the + // disposable reference maps were built with the current collision-free + // identity encoding. Reject stale or corrupted maps before any query. + if (stableJson(index) !== stableJson(buildStateIndex(snapshot))) { + throw new StateEngineError('State index is not semantically equivalent to canonical snapshot'); + } + return index; + } catch (error) { + if (!rebuildIfNeeded) throw error; + return rebuildStateIndex(rootDir); + } +} + +function buildEvent({ + sequence, + previousEventHash, + timestamp: at, + actorClass, + type, + id: entityIdentifier = null, + entity: entityKind = null, + refs = {}, + payload, +}) { + const base = { + schemaVersion: STATE_EVENT_SCHEMA_VERSION, + sequence, + timestamp: timestamp(at, 'event timestamp'), + actorClass: requireText(actorClass, 'actorClass'), + eventType: eventType(type), + entityType: entityKind === null ? null : entityType(entityKind), + entityId: entityIdentifier === null ? null : entityId(entityIdentifier), + lifecycleId: optionalText(refs.lifecycleId, 'lifecycleId'), + taskId: optionalText(refs.taskId, 'taskId'), + contractId: optionalText(refs.contractId, 'contractId'), + runId: optionalText(refs.runId, 'runId'), + payload: cloneSerializable(payload, 'Event payload'), + previousEventHash: previousEventHash ?? null, + }; + const eventId = computeEventId(base); + const event = { + ...base, + eventId, + }; + event.eventHash = computeEventHash(event); + return event; +} + +function appendDurably(filePath, appendedText, expectedIdentity = null, pinnedDirFd = null, heldLedgerFd = null) { + const directory = path.dirname(filePath); + const file = path.basename(filePath); + const ownsDirFd = !pinnedDirFd; + const dirFd = pinnedDirFd ?? fs.openSync(directory, 'r'); + try { + const pinned = fs.fstatSync(dirFd); + const observed = fs.lstatSync(directory); + if (!pinned.isDirectory() || !observed.isDirectory() || observed.isSymbolicLink() || + pinned.dev !== observed.dev || pinned.ino !== observed.ino) { + throw new StateEngineError('State Engine directory changed before durable append'); + } + const rootDir = path.dirname(path.dirname(directory)); + assertStateRootRealpathSafe({ root: rootDir, stateRoot: directory }); + const target = process.platform === 'linux' ? path.join('/proc/self/fd/' + dirFd, file) : filePath; + // O_NOFOLLOW rejects a final-component symlink on supported POSIX hosts. + // On Windows, verify the opened inode and current directory entry before + // writing because path-only preflight cannot prevent a replacement race. + const flags = fs.constants.O_WRONLY | fs.constants.O_APPEND | (fs.constants.O_NOFOLLOW ?? 0); + const fd = fs.openSync(target, flags); + try { + const stat = fs.fstatSync(fd); + const entry = fs.lstatSync(target); + if (!stat.isFile() || stat.nlink !== 1 || entry.isSymbolicLink() || + !entry.isFile() || entry.nlink !== 1 || + entry.dev !== stat.dev || entry.ino !== stat.ino) { + throw new StateEngineError('Canonical event file symlink/inode mismatch or multiple hard links'); + } + if (heldLedgerFd !== null) { + const heldStat = fs.fstatSync(heldLedgerFd); + if (heldStat.dev !== stat.dev || heldStat.ino !== stat.ino) { + throw new StateEngineError('Canonical ledger inode changed while holding preparation descriptor'); + } + } + if (expectedIdentity && (stat.dev !== expectedIdentity.dev || + stat.ino !== expectedIdentity.ino || stat.size !== expectedIdentity.size)) { + throw new StateEngineError('Canonical event inode changed between event preparation and append'); + } + // Recheck directory immediately before write on platforms without descriptor-relative paths + const latest = fs.lstatSync(directory); + if (!latest.isDirectory() || latest.isSymbolicLink() || + latest.dev !== pinned.dev || latest.ino !== pinned.ino) { + throw new StateEngineError('State Engine directory was replaced before durable append'); + } + const bytes = Buffer.from(appendedText, 'utf8'); + let written = 0; + while (written < bytes.length) { + const amount = fs.writeSync(fd, bytes, written, bytes.length - written); + if (amount <= 0) throw new StateEngineError('Canonical event append made no forward progress'); + written += amount; + } + fs.fsyncSync(fd); + // Re-verify the directory entry after fsync to guarantee the canonical + // path still references this exact inode and was not unlinked or swapped + // during write (PRRC_kwDOTd-YNM79WYGR). + const finalEntry = fs.lstatSync(target); + const finalStat = fs.fstatSync(fd); + if (!finalEntry.isFile() || finalEntry.isSymbolicLink() || finalEntry.nlink !== 1 || + finalEntry.dev !== finalStat.dev || finalEntry.ino !== finalStat.ino) { + throw new StateEngineError('Canonical ledger directory entry was displaced during durable append'); + } + } finally { + fs.closeSync(fd); + } + flushStateDirectory(directory, dirFd); + } finally { + if (ownsDirFd) fs.closeSync(dirFd); + } +} + +function recoverPendingCommitLocked(rootDir) { + const paths = ensureStateEngineLayout(rootDir); + const dirFd = fs.openSync(paths.stateRoot, 'r'); + try { + const pinned = fs.fstatSync(dirFd); + const observed = fs.lstatSync(paths.stateRoot); + if (!pinned.isDirectory() || !observed.isDirectory() || observed.isSymbolicLink() || + pinned.dev !== observed.dev || pinned.ino !== observed.ino) { + throw new StateEngineError('State Engine directory changed before recovery preparation'); + } + const pendingTarget = process.platform === 'linux' + ? path.join('/proc/self/fd/' + dirFd, path.basename(paths.pending)) + : paths.pending; + const eventsTarget = process.platform === 'linux' + ? path.join('/proc/self/fd/' + dirFd, path.basename(paths.events)) + : paths.events; + const snapshotTarget = process.platform === 'linux' + ? path.join('/proc/self/fd/' + dirFd, path.basename(paths.snapshot)) + : paths.snapshot; + + if (!fs.existsSync(pendingTarget)) return; + if (!fs.existsSync(eventsTarget)) { + throw new StateEngineError('Cannot recover pending commit: canonical ledger events.jsonl is missing'); + } + let pending; + try { + pending = JSON.parse(fs.readFileSync(pendingTarget, 'utf8')); + } catch (error) { + throw new StateEngineError('Pending State Engine commit is invalid: ' + error.message); + } + if ( + pending.schemaVersion !== '1.0.0' + || !Number.isSafeInteger(pending.previousByteLength) + || pending.previousByteLength < 0 + || !SHA256_PATTERN.test(pending.previousHash) + || typeof pending.suffix !== 'string' + || !pending.suffix.endsWith('\n') + ) { + throw new StateEngineError('Pending State Engine commit metadata is invalid'); + } + // Bind every recovery operation to the same verified canonical descriptor. + // Pathname truncation can otherwise modify a hardlinked external file when + // an attacker replaces events.jsonl after the prefix has been inspected. + const flags = fs.constants.O_RDWR | (fs.constants.O_NOFOLLOW ?? 0); + const fd = fs.openSync(eventsTarget, flags); + let canonical; + let canonicalStat; + try { + const opened = fs.fstatSync(fd); + canonicalStat = opened; + const entry = fs.lstatSync(eventsTarget); + if (!opened.isFile() || !entry.isFile() || entry.isSymbolicLink() || + opened.nlink !== 1 || entry.nlink !== 1 || + opened.dev !== entry.dev || opened.ino !== entry.ino) { + throw new StateEngineError('Recovery canonical ledger inode/symlink/hardlink integrity failed'); + } + const bytes = Buffer.alloc(opened.size); + let read = 0; + while (read < bytes.length) { + const amount = fs.readSync(fd, bytes, read, bytes.length - read, read); + if (amount <= 0) throw new StateEngineError('Canonical ledger changed while recovering pending commit'); + read += amount; + } + if (bytes.length < pending.previousByteLength) { + throw new StateEngineError('Canonical state history was truncated during a pending commit'); + } + if (sha256(bytes.subarray(0, pending.previousByteLength)) !== pending.previousHash) { + throw new StateEngineError('Canonical state prefix differs from pending commit integrity witness'); + } + const existingSuffix = bytes.subarray(pending.previousByteLength); + const expectedSuffix = Buffer.from(pending.suffix, 'utf8'); + if (!expectedSuffix.subarray(0, existingSuffix.length).equals(existingSuffix)) { + throw new StateEngineError('Canonical state suffix conflicts with pending commit; refusing recovery'); + } + const proposedEvents = parseLedger( + Buffer.concat([bytes.subarray(0, pending.previousByteLength), expectedSuffix]).toString('utf8'), + ); + canonical = rebuildSnapshotFromEvents(proposedEvents); + // Validate the entire proposed ledger and the materialized snapshot witness + // before any truncate/append/fsync can mutate canonical crash evidence. + let witness = null; + let witnessBytes; + try { + witnessBytes = fs.readFileSync(snapshotTarget, 'utf8'); + } catch (error) { + // A missing disposable snapshot may be rebuilt from canonical events. + // Permission, sharing and I/O failures are different: ignoring them + // would allow canonical ledger mutation without inspecting an existing + // witness that may prove the proposed pending suffix is stale. + if (error.code !== 'ENOENT') { + throw new StateEngineError( + 'Cannot inspect materialized snapshot witness before pending recovery: ' + + (error.code || error.message), + ); + } + } + if (witnessBytes !== undefined) { + try { + witness = JSON.parse(witnessBytes); + validateStateSnapshot(witness); + } catch { + // Malformed derived state is disposable, unlike unreadable evidence. + witness = null; + } + } + if (witness && witness.lastEventSequence > canonical.lastEventSequence) { + throw new StateEngineError('Cannot recover a pending commit over a later snapshot integrity witness'); + } + if (witness && witness.lastEventSequence === canonical.lastEventSequence + && witness.lastEventHash !== canonical.lastEventHash) { + throw new StateEngineError('Pending recovery conflicts with snapshot integrity witness'); + } + // A surviving index may witness later committed history even when the + // snapshot is missing or corrupt. Verify it before any ledger mutation. + assertDerivedHistoryWitnesses(paths, proposedEvents); + if (existingSuffix.length < expectedSuffix.length) { + // Truncate and repair through this FD, never a separately resolved path. + fs.ftruncateSync(fd, pending.previousByteLength); + let written = 0; + while (written < expectedSuffix.length) { + const amount = fs.writeSync(fd, expectedSuffix, written, + expectedSuffix.length - written, pending.previousByteLength + written); + if (amount <= 0) throw new StateEngineError('Pending recovery append made no forward progress'); + written += amount; + } + } + // Recovery can observe a fully written suffix that was never fsynced by + // the interrupted writer. Flush the canonical ledger unconditionally + // before publishing derived views or acknowledging the pending journal. + fs.fsyncSync(fd); + // Re-verify the directory entry after fsync to ensure the canonical path + // still names the repaired inode before publishing derived views (PRRC_kwDOTd-YNM79WYGR). + const finalEntry = fs.lstatSync(eventsTarget); + const finalStat = fs.fstatSync(fd); + if (!finalEntry.isFile() || finalEntry.isSymbolicLink() || finalEntry.nlink !== 1 || + finalEntry.dev !== finalStat.dev || finalEntry.ino !== finalStat.ino) { + throw new StateEngineError('Canonical ledger directory entry was displaced during pending recovery'); + } + persistDerived(canonical, rootDir, dirFd); + pinnedUnlinkAndSync(paths.pending, dirFd); + } finally { + fs.closeSync(fd); + } + } finally { + fs.closeSync(dirFd); + } +} + +const ACTIVE_STATE_LOCKS = new Set(); +function withStateLock(rootDir, task) { + const key = path.resolve(rootDir); + if (ACTIVE_STATE_LOCKS.has(key)) return task(); + const lock = acquireStateLock(rootDir); + ACTIVE_STATE_LOCKS.add(key); + let primaryError = null; + try { + recoverPendingCommitLocked(rootDir); + return task(); + } catch (error) { + primaryError = error; + throw error; + } finally { + ACTIVE_STATE_LOCKS.delete(key); + try { + releaseStateLock(lock); + } catch (releaseError) { + if (primaryError) { + // A failed transaction must retain its original filesystem/security + // cause. Record a secondary release failure without replacing the + // primary error or silently claiming the lock was cleaned up. + if (primaryError && typeof primaryError === 'object') { + primaryError.lockReleaseError = releaseError; + } + } else { + throw releaseError; + } + } + } +} + +function commitPreparedEventsLocked(newEvents, rootDir = process.cwd()) { + if (!Array.isArray(newEvents) || newEvents.length === 0) { + const snapshot = loadStateSnapshot(rootDir, { rebuildIfNeeded: true }) ?? emptySnapshot(); + return { events: [], snapshot, index: loadStateIndex(rootDir, { rebuildIfNeeded: true }) }; + } + + const paths = ensureStateEngineLayout(rootDir); + const dirFd = fs.openSync(paths.stateRoot, 'r'); + try { + const pinned = fs.fstatSync(dirFd); + const observed = fs.lstatSync(paths.stateRoot); + if (!pinned.isDirectory() || !observed.isDirectory() || observed.isSymbolicLink() || + pinned.dev !== observed.dev || pinned.ino !== observed.ino) { + throw new StateEngineError('State Engine directory changed before durable write preparation'); + } + const rootDirParent = path.dirname(path.dirname(paths.stateRoot)); + assertStateRootRealpathSafe({ root: rootDirParent, stateRoot: paths.stateRoot }); + + const ledgerPath = process.platform === 'linux' + ? path.join('/proc/self/fd/' + dirFd, path.basename(paths.events)) + : paths.events; + + // Open ledger with descriptor-bound flags and read through this descriptor + // so preparation and identity witness come from the exact same descriptor. + // Retain this descriptor open across atomicWrite and appendDurably so the inode + // cannot be unlinked and recycled while the commit is in flight (PRRT_kwDOTd-YNM6ri6bH). + const readFlags = fs.constants.O_RDONLY | (fs.constants.O_NOFOLLOW ?? 0); + const readFd = fs.openSync(ledgerPath, readFlags); + let existingContent; + let expectedIdentity; + try { + const openedStat = fs.fstatSync(readFd); + const entryStat = fs.lstatSync(ledgerPath); + if (!openedStat.isFile() || openedStat.nlink !== 1 || entryStat.isSymbolicLink() || + !entryStat.isFile() || entryStat.nlink !== 1 || + openedStat.dev !== entryStat.dev || openedStat.ino !== entryStat.ino) { + throw new StateEngineError('Canonical event file symlink/inode mismatch or multiple hard links'); + } + expectedIdentity = { dev: openedStat.dev, ino: openedStat.ino, size: openedStat.size }; + const bytes = Buffer.alloc(openedStat.size); + let read = 0; + while (read < bytes.length) { + const amount = fs.readSync(readFd, bytes, read, bytes.length - read, read); + if (amount <= 0 && read < bytes.length) { + throw new StateEngineError('Canonical ledger changed while reading during preparation'); + } + if (amount <= 0) break; + read += amount; + } + existingContent = bytes.toString('utf8'); + + const existing = parseLedger(existingContent); + // Inspect both acknowledged derived witnesses against the original + // canonical prefix, not the proposed post-append sequence: a new event + // must never conceal a valid truncation of previously accepted history. + assertDerivedHistoryWitnesses(paths, existing); + let previous = existing.at(-1) ?? null; + for (const event of newEvents) { + validateStateEvent(event, previous); + previous = event; + } + const allEvents = [...existing, ...newEvents]; + // Validate and reconstruct before beginning a durable state mutation. + const nextSnapshot = rebuildSnapshotFromEvents(allEvents); + // A valid last event may lack the optional terminating newline. Preserve + // record separation when appending, including in the crash-recovery journal. + const separator = existingContent.length > 0 && !existingContent.endsWith('\n') ? '\n' : ''; + const suffix = separator + newEvents.map((event) => JSON.stringify(stable(event))).join('\n') + '\n'; + const pending = { + schemaVersion: '1.0.0', + previousByteLength: Buffer.byteLength(existingContent, 'utf8'), + previousHash: sha256(Buffer.from(existingContent, 'utf8')), + suffix, + }; + + atomicWrite(paths.pending, stablePretty(pending), dirFd); + // Physical append avoids O(history) rewrites; the pending journal permits + // deterministic repair after a torn write or interrupted snapshot persist. + appendDurably(paths.events, suffix, expectedIdentity, dirFd, readFd); + const { index } = persistDerived(nextSnapshot, rootDir, dirFd); + pinnedUnlinkAndSync(paths.pending, dirFd); + return { events: newEvents, snapshot: nextSnapshot, index }; + } finally { + fs.closeSync(readFd); + } + } finally { + fs.closeSync(dirFd); + } +} + +function nextEventContext(rootDir) { + const events = loadCanonicalEvents(rootDir); + const previous = events.at(-1) ?? null; + return { + sequence: previous ? previous.sequence + 1 : 1, + previousEventHash: previous?.eventHash ?? null, + previous, + }; +} + +export function appendEntityState({ + rootDir = process.cwd(), + entityType: type, + entityId: id, + state, + refs = {}, + actorClass = 'runtime', + timestamp: at = new Date().toISOString(), +} = {}) { + const normalizedType = entityType(type); + const normalizedId = entityId(id); + const nextState = cloneSerializable(state, 'Entity state'); + const revision = stateRevision(nextState.stateRevision); + + return withStateLock(rootDir, () => { + const currentSnapshot = loadStateSnapshot(rootDir, { rebuildIfNeeded: true }) ?? emptySnapshot(); + const current = ownValue(ownValue(currentSnapshot.entities, normalizedType), normalizedId) ?? null; + + if (current && revision < current.stateRevision) { + throw new StateEngineError('Refusing stateRevision regression for ' + normalizedType + '/' + normalizedId); + } + if (current && stableJson(current.state) === stableJson(nextState)) { + return Object.freeze({ changed: false, event: null, snapshot: currentSnapshot }); + } + + const context = nextEventContext(rootDir); + const payload = current + ? { stateRevision: revision, operations: diffValues(current.state, nextState) } + : { stateRevision: revision, state: nextState }; + const event = buildEvent({ + sequence: context.sequence, + previousEventHash: context.previousEventHash, + timestamp: at, + actorClass, + type: current ? 'ENTITY_STATE_UPDATED' : 'ENTITY_STATE_INITIALIZED', + id: normalizedId, + entity: normalizedType, + refs, + payload, + }); + const committed = commitPreparedEventsLocked([event], rootDir); + return Object.freeze({ changed: true, event, snapshot: committed.snapshot }); + }); +} + +export function appendMetadataEvent({ + rootDir = process.cwd(), + eventType: type, + payload, + refs = {}, + actorClass = 'runtime', + timestamp: at = new Date().toISOString(), +} = {}) { + return withStateLock(rootDir, () => { + const context = nextEventContext(rootDir); + const event = buildEvent({ + sequence: context.sequence, + previousEventHash: context.previousEventHash, + timestamp: at, + actorClass, + type, + id: null, + entity: null, + refs, + payload, + }); + const committed = commitPreparedEventsLocked([event], rootDir); + return Object.freeze({ event, snapshot: committed.snapshot }); + }); +} + +export function loadEntityState(type, id, rootDir = process.cwd()) { + const normalizedType = entityType(type); + const normalizedId = entityId(id); + const snapshot = loadStateSnapshot(rootDir, { rebuildIfNeeded: true }); + const record = ownValue(ownValue(snapshot?.entities, normalizedType), normalizedId); + return record ? structuredClone(record.state) : null; +} + +export function listEntityRecords(type, rootDir = process.cwd()) { + const normalizedType = entityType(type); + const snapshot = loadStateSnapshot(rootDir, { rebuildIfNeeded: true }); + const bucket = ownValue(snapshot?.entities, normalizedType) ?? {}; + return Object.entries(bucket) + .map(([id, record]) => ({ + entityId: id, + stateRevision: record.stateRevision, + lastEventSequence: record.lastEventSequence, + refs: structuredClone(record.refs), + })) + .sort((a, b) => a.entityId.localeCompare(b.entityId)); +} + +export function loadLatestEntityState(type, rootDir = process.cwd()) { + const normalizedType = entityType(type); + const snapshot = loadStateSnapshot(rootDir, { rebuildIfNeeded: true }); + const bucket = ownValue(snapshot?.entities, normalizedType) ?? {}; + const records = Object.entries(bucket) + .map(([id, record]) => ({ id, record })) + .sort((a, b) => b.record.lastEventSequence - a.record.lastEventSequence || a.id.localeCompare(b.id)); + if (records.length === 0) return null; + return structuredClone(records[0].record.state); +} + +export function hasCompletedStateMigration(rootDir = process.cwd(), sourceFingerprint = null) { + const snapshot = loadStateSnapshot(rootDir, { rebuildIfNeeded: true }); + const migrations = snapshot?.migrations ?? []; + if (sourceFingerprint === null || sourceFingerprint === undefined) { + return migrations.some((migration) => migration.semanticEquivalence === true); + } + return migrations.some((migration) => ( + migration.semanticEquivalence === true + && migration.sourceFingerprint === sourceFingerprint + )); +} + +export function queryStateIndex({ + rootDir = process.cwd(), + contractId = null, + taskId = null, + runId = null, + lifecycleId = null, +} = {}) { + const index = loadStateIndex(rootDir, { rebuildIfNeeded: true }); + const filters = [ + ['byContract', contractId], + ['byTask', taskId], + ['byRun', runId], + ['byLifecycle', lifecycleId], + ].filter(([, value]) => value !== null && value !== undefined); + + if (filters.length === 0) return structuredClone(index.records); + + let keys = null; + for (const [field, value] of filters) { + const current = new Set(ownValue(index[field], String(value)) ?? []); + keys = keys === null ? current : new Set([...keys].filter((key) => current.has(key))); + } + return index.records.filter((record) => keys.has(record.key)).map((record) => structuredClone(record)); +} + +export function verifyStateEngineIntegrity(rootDir = process.cwd()) { + // Preserve the established H17 integrity-check contract: this explicit + // recovery entry point may complete a pending commit before verification. + const events = loadCanonicalEvents(rootDir); + const canonical = rebuildSnapshotFromEvents(events); + const snapshot = loadStateSnapshot(rootDir, { rebuildIfNeeded: false }); + const index = loadStateIndex(rootDir, { rebuildIfNeeded: false }); + + if (snapshot === null) throw new StateEngineError('State snapshot is missing'); + if (stableJson(snapshot) !== stableJson(canonical)) { + throw new StateEngineError('State snapshot is not semantically equivalent to canonical history'); + } + validateStateIndex(index, snapshot); + const rebuiltIndex = buildStateIndex(snapshot); + if (stableJson(index) !== stableJson(rebuiltIndex)) { + throw new StateEngineError('State index is not semantically equivalent to rebuilt index'); + } + + return Object.freeze({ + valid: true, + eventCount: events.length, + entityCount: Object.values(snapshot.entities).reduce((sum, bucket) => sum + Object.keys(bucket).length, 0), + lastEventSequence: snapshot.lastEventSequence, + lastEventHash: snapshot.lastEventHash, + }); +} + +export function cleanupOrphanedStateTemps(rootDir = process.cwd()) { + const paths = statePaths(rootDir); + if (!fs.existsSync(paths.stateRoot)) return []; + const removed = []; + for (const name of fs.readdirSync(paths.stateRoot)) { + // Only internal publication stages may be garbage-collected. In + // particular, never delete a live writer's temporary file: a concurrent + // atomic rename would then fail after its bytes were already fsynced. + if (!/^(?:events\.jsonl|snapshot\.json|index\.db|schema\.json|pending-commit\.json)\.tmp-/.test(name)) continue; + const owner = /\.tmp-([1-9][0-9]*)-[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.exec(name); + if (owner) { + const pid = Number(owner[1]); + if (!Number.isSafeInteger(pid)) continue; + try { + process.kill(pid, 0); + continue; // Owner exists: do not interfere with its publication. + } catch (error) { + if (error.code !== 'ESRCH') continue; // Unknown/denied = live. + } + } + const candidate = path.join(paths.stateRoot, name); + let stat; + try { stat = fs.lstatSync(candidate); } + catch (error) { + if (error.code === 'ENOENT') continue; + throw error; + } + // Hardlinked bootstrap witnesses require their own validated recovery. + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1) continue; + fs.unlinkSync(candidate); + removed.push(candidate); + } + return removed; +} + +export function getStateEnginePaths(rootDir = process.cwd()) { + return statePaths(rootDir); +} + +export function countStateEngineFiles(rootDir = process.cwd()) { + const paths = statePaths(rootDir); + if (!fs.existsSync(paths.stateRoot)) return 0; + return fs.readdirSync(paths.stateRoot, { withFileTypes: true }) + .filter((entry) => entry.isFile() && entry.name !== 'state.lock' && entry.name !== 'pending-commit.json' && !entry.name.includes('.tmp-')) + .length; +} + +export { stableJson as canonicalStateJson, sha256 as stateSha256 }; diff --git a/runtime/orchestration/state-migration-gate.mjs b/runtime/orchestration/state-migration-gate.mjs new file mode 100644 index 000000000..57132f882 --- /dev/null +++ b/runtime/orchestration/state-migration-gate.mjs @@ -0,0 +1,109 @@ +/** + * Serializes migration inspection/backup/cutover with every supported legacy + * state writer. This is a local, cooperative cross-process gate, NOT a defence + * against arbitrary same-account hostile processes (Issue #72). + * + * An orphaned gate is deliberately NOT reclaimed by elapsed time: an + * uncertain writer or migration must never be bypassed. Offline recovery + * requires proving all participating processes have stopped and preserving + * the lock bytes before an operator removes it. + */ +import crypto from 'node:crypto'; +import fs from 'node:fs'; +import path from 'node:path'; + +const SLEEP = new Int32Array(new SharedArrayBuffer(4)); +const LOCK_NAME = '.state-migration.lock'; + +export class MigrationGateError extends Error { + constructor(message) { + super(message); + this.name = 'MigrationGateError'; + } +} + +function assertSafeDirectory(directory) { + const stat = fs.lstatSync(directory); + if (!stat.isDirectory() || stat.isSymbolicLink()) { + throw new MigrationGateError('Migration gate parent is not a real directory'); + } +} + +function inspectGate(lockPath) { + try { + const stat = fs.lstatSync(lockPath, { bigint: true }); + if (!stat.isFile() || stat.isSymbolicLink() || stat.nlink !== 1n) { + throw new MigrationGateError('Unsafe migration gate link or object type'); + } + return stat; + } catch (error) { + if (error.code === 'ENOENT') return null; + throw error; + } +} + +export function withMigrationGate(rootDir, operation, { timeoutMs = 2000 } = {}) { + const root = path.resolve(rootDir); + assertSafeDirectory(root); + const parent = path.join(root, '.development-kit'); + fs.mkdirSync(parent, { recursive: true }); + assertSafeDirectory(parent); + const lockPath = path.join(parent, LOCK_NAME); + const token = crypto.randomUUID(); + const start = Date.now(); + let fd; + for (;;) { + try { + fd = fs.openSync(lockPath, 'wx', 0o600); + break; + } catch (error) { + if (error.code !== 'EEXIST') throw error; + inspectGate(lockPath); + if (Date.now() - start >= timeoutMs) { + throw new MigrationGateError( + 'Migration/cutover gate is held or orphaned; preserve it and retry only after safe operator recovery', + ); + } + Atomics.wait(SLEEP, 0, 0, 25); + } + } + let identity; + try { + identity = fs.fstatSync(fd, { bigint: true }); + fs.writeFileSync(fd, JSON.stringify({ + schemaVersion: 1, token, pid: process.pid, acquiredAt: new Date().toISOString(), + }) + '\n', 'utf8'); + fs.fsyncSync(fd); + } finally { + fs.closeSync(fd); + } + let outcome; + let operationError; + try { + outcome = operation(); + } catch (error) { + operationError = error; + } + let releaseError; + try { + assertSafeDirectory(parent); + const current = inspectGate(lockPath); + if (!current || current.dev !== identity.dev || current.ino !== identity.ino) { + throw new MigrationGateError('Migration gate identity changed; preserving evidence'); + } + const record = JSON.parse(fs.readFileSync(lockPath, 'utf8')); + if (record.token !== token || record.pid !== process.pid) { + throw new MigrationGateError('Migration gate ownership changed; preserving evidence'); + } + fs.unlinkSync(lockPath); + } catch (error) { + releaseError = error; + } + if (operationError && releaseError) { + throw new AggregateError([operationError, releaseError], + 'Migration operation and gate cleanup both failed; outcome uncertain'); + } + if (operationError) throw operationError; + if (releaseError) throw releaseError; + return outcome; +} diff --git a/schemas/state-engine-event.schema.json b/schemas/state-engine-event.schema.json new file mode 100644 index 000000000..97b77069b --- /dev/null +++ b/schemas/state-engine-event.schema.json @@ -0,0 +1,107 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://development-kit.dev/schemas/state-engine-event.schema.json", + "title": "Development Kit State Engine V2 Event", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "sequence", + "eventId", + "timestamp", + "actorClass", + "eventType", + "entityType", + "entityId", + "lifecycleId", + "taskId", + "contractId", + "runId", + "payload", + "previousEventHash", + "eventHash" + ], + "properties": { + "schemaVersion": { + "const": "1.0.0" + }, + "sequence": { + "type": "integer", + "minimum": 1 + }, + "eventId": { + "type": "string", + "pattern": "^evt-[0-9]{10}-[a-f0-9]{20}$" + }, + "timestamp": { + "type": "string", + "format": "date-time" + }, + "actorClass": { + "type": "string", + "minLength": 1 + }, + "eventType": { + "type": "string", + "pattern": "^[A-Z][A-Z0-9_]{1,95}$" + }, + "entityType": { + "type": [ + "string", + "null" + ] + }, + "entityId": { + "type": [ + "string", + "null" + ] + }, + "lifecycleId": { + "type": [ + "string", + "null" + ] + }, + "taskId": { + "type": [ + "string", + "null" + ] + }, + "contractId": { + "type": [ + "string", + "null" + ] + }, + "runId": { + "type": [ + "string", + "null" + ] + }, + "payload": { + "type": "object" + }, + "previousEventHash": { + "anyOf": [ + { + "$ref": "#/$defs/sha256" + }, + { + "type": "null" + } + ] + }, + "eventHash": { + "$ref": "#/$defs/sha256" + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } +} diff --git a/schemas/state-engine-index.schema.json b/schemas/state-engine-index.schema.json new file mode 100644 index 000000000..d7ed58a2f --- /dev/null +++ b/schemas/state-engine-index.schema.json @@ -0,0 +1,65 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://development-kit.dev/schemas/state-engine-index.schema.json", + "title": "Development Kit State Engine V2 Disposable Index", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "sourceSequence", + "sourceHash", + "recordCount", + "records", + "byContract", + "byTask", + "byRun", + "byLifecycle" + ], + "properties": { + "schemaVersion": { + "const": "1.0.0" + }, + "sourceSequence": { + "type": "integer", + "minimum": 0 + }, + "sourceHash": { + "anyOf": [ + { + "$ref": "#/$defs/sha256" + }, + { + "type": "null" + } + ] + }, + "recordCount": { + "type": "integer", + "minimum": 0 + }, + "records": { + "type": "array", + "items": { + "type": "object" + } + }, + "byContract": { + "type": "object" + }, + "byTask": { + "type": "object" + }, + "byRun": { + "type": "object" + }, + "byLifecycle": { + "type": "object" + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } +} diff --git a/schemas/state-engine-snapshot.schema.json b/schemas/state-engine-snapshot.schema.json new file mode 100644 index 000000000..729cb03d2 --- /dev/null +++ b/schemas/state-engine-snapshot.schema.json @@ -0,0 +1,82 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://development-kit.dev/schemas/state-engine-snapshot.schema.json", + "title": "Development Kit State Engine V2 Snapshot", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", + "engineSchemaVersion", + "lastEventSequence", + "lastEventHash", + "entities", + "migrations" + ], + "properties": { + "schemaVersion": { + "const": "2.0.0" + }, + "engineSchemaVersion": { + "const": "2.0.0" + }, + "lastEventSequence": { + "type": "integer", + "minimum": 0 + }, + "lastEventHash": { + "anyOf": [ + { + "$ref": "#/$defs/sha256" + }, + { + "type": "null" + } + ] + }, + "entities": { + "type": "object", + "additionalProperties": { + "type": "object", + "additionalProperties": { + "type": "object", + "required": [ + "stateRevision", + "state", + "lastEventSequence", + "lastEventHash", + "refs" + ], + "properties": { + "stateRevision": { + "type": "integer", + "minimum": 1 + }, + "state": {}, + "lastEventSequence": { + "type": "integer", + "minimum": 1 + }, + "lastEventHash": { + "$ref": "#/$defs/sha256" + }, + "refs": { + "type": "object" + } + } + } + } + }, + "migrations": { + "type": "array", + "items": { + "type": "object" + } + } + }, + "$defs": { + "sha256": { + "type": "string", + "pattern": "^sha256:[a-f0-9]{64}$" + } + } +} diff --git a/scripts/autopilot.test.mjs b/scripts/autopilot.test.mjs index 22f382f6e..b469d7fb8 100644 --- a/scripts/autopilot.test.mjs +++ b/scripts/autopilot.test.mjs @@ -92,6 +92,12 @@ test('3. Immutable Revision Persistence & Reading', () => { test('4. Corrupt Pointer Recovery', () => { const tmpDir = createTempDir(); const state1 = createInitialState({ autonomy: 'guided-autopilot' }, tmpDir); + // This test intentionally corrupts the legacy current.json pointer. Seed + // an unmigrated legacy revision so the v0.13 compatibility adapter keeps + // using the legacy authority rather than a fresh canonical V2 ledger. + const legacyDir = path.join(tmpDir, '.development-kit', 'autopilot', 'state'); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'revision-000001.json'), JSON.stringify(state1, null, 2)); saveStateRevision(state1, tmpDir); // Corrupt current.json diff --git a/scripts/lifecycle-instances.test.mjs b/scripts/lifecycle-instances.test.mjs index 53090c26a..9a3469208 100644 --- a/scripts/lifecycle-instances.test.mjs +++ b/scripts/lifecycle-instances.test.mjs @@ -232,6 +232,12 @@ test('AC-016: implicit legacy lifecycle rehydrates stable persisted snapshot wit state.currentStage = 'REVIEW'; state.completedStages = ['UNDERSTAND', 'DEFINE', 'DESIGN', 'PLAN', 'IMPLEMENT', 'VERIFY']; state.stateRevision = 3; + // This fixture specifically exercises an existing, unmigrated legacy state + // authority. Fresh v0.13 projects intentionally use the canonical ledger; + // seed an actual legacy revision before invoking the compatibility adapter. + const legacyStateDir = path.join(rootDir, '.development-kit', 'autopilot', 'state'); + fs.mkdirSync(legacyStateDir, { recursive: true }); + fs.writeFileSync(path.join(legacyStateDir, 'revision-000003.json'), JSON.stringify(state, null, 2)); saveStateRevision(state, rootDir); const pointer = path.join(rootDir, '.development-kit', 'autopilot', 'state', 'current.json'); const previous = fs.readFileSync(pointer); diff --git a/scripts/phase01-scale-context-baseline.mjs b/scripts/phase01-scale-context-baseline.mjs index bbba912e1..00d3d26e2 100644 --- a/scripts/phase01-scale-context-baseline.mjs +++ b/scripts/phase01-scale-context-baseline.mjs @@ -140,12 +140,20 @@ function legacyStateFixture(revisions = 26) { const root = tempProject('legacy-state'); try { const state = createInitialState({ autonomy: 'guided-autopilot' }, root); + // This is a *historical legacy micro-file* baseline, not a measurement of + // the v0.13 canonical ledger. Explicitly seed a valid legacy revision so + // the compatibility adapter exercises the old persistence authority. + // The first save overwrites this seed and writes the normal current.json + // pointer; the measured file count remains exactly revisions + 1. + const stateDir = getStateDir(root); + fs.mkdirSync(stateDir, { recursive: true }); + fs.writeFileSync(path.join(stateDir, 'revision-000001.json'), + JSON.stringify({ ...state, stateRevision: 1 }, null, 2)); for (let revision = 1; revision <= revisions; revision += 1) { state.stateRevision = revision; state.updatedAt = new Date(Date.parse(state.createdAt) + revision * 1000).toISOString(); saveStateRevision(state, root); } - const stateDir = getStateDir(root); const files = fs.readdirSync(stateDir).filter((name) => fs.statSync(path.join(stateDir, name)).isFile()); const revisionFiles = files.filter((name) => /^revision-\d{6}\.json$/.test(name)); const bytes = files.reduce((sum, name) => sum + fs.statSync(path.join(stateDir, name)).size, 0); diff --git a/scripts/state-engine-v013.test.mjs b/scripts/state-engine-v013.test.mjs new file mode 100644 index 000000000..e8644e899 --- /dev/null +++ b/scripts/state-engine-v013.test.mjs @@ -0,0 +1,1112 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { + appendMetadataEvent, + getStateEnginePaths, + loadCanonicalEvents, + loadEntityState, + loadStateSnapshot, + rebuildStateIndex, + verifyStateEngineIntegrity, +} from '../runtime/orchestration/state-engine-v2.mjs'; +import { + getLegacyBackupPath, + migrateLegacyStateToV2, + recoverLegacyRestore, + restoreLegacyBackup, + validateWindowsDurabilityHelperResult, +} from '../runtime/orchestration/state-engine-migration.mjs'; +import { createInitialState } from '../runtime/autopilot/transition-model.mjs'; +import { saveStateRevision } from '../runtime/autopilot/state-store.mjs'; +import { RuntimeApiService } from '../runtime/api/runtime-api-service.mjs'; +import { persistRunManifest, persistRunStateRevision, updateRun } from '../runtime/orchestration/orchestration-run.mjs'; + +function tempDir(t, prefix = 'dk-v013-state-') { + const root = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => fs.rmSync(root, { recursive: true, force: true })); + return root; +} + +function captureMaterialized(paths) { + return { + events: fs.readFileSync(paths.events, 'utf8'), + snapshot: fs.readFileSync(paths.snapshot, 'utf8'), + index: fs.readFileSync(paths.index, 'utf8'), + }; +} + +function restoreMaterialized(paths, captured) { + fs.writeFileSync(paths.events, captured.events, 'utf8'); + fs.writeFileSync(paths.snapshot, captured.snapshot, 'utf8'); + fs.writeFileSync(paths.index, captured.index, 'utf8'); + if (fs.existsSync(paths.pending)) fs.rmSync(paths.pending, { force: true }); +} + +function createLegacyBackupFixture(t) { + const sourceRoot = tempDir(t, 'dk-v013-legacy-source-'); + const state = createInitialState({ autonomy: 'guided-autopilot' }, sourceRoot); + saveStateRevision(state, sourceRoot); + const migration = migrateLegacyStateToV2({ + rootDir: sourceRoot, + completedAt: '2026-10-14T20:00:00.000Z', + }); + assert.equal(migration.semanticEquivalence, true); + const backupPath = getLegacyBackupPath(sourceRoot); + assert.equal(fs.existsSync(backupPath), true); + return { sourceRoot, backupPath }; +} + +test('V13-LEDGER-01 pending journal loss cannot strand an unambiguous torn trailing append', (t) => { + const rootDir = tempDir(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const baseline = captureMaterialized(paths); + + appendMetadataEvent({ rootDir, eventType: 'UNACKNOWLEDGED_EVENT', payload: { value: 2 } }); + const full = fs.readFileSync(paths.events, 'utf8'); + const suffix = full.slice(baseline.events.length); + + restoreMaterialized(paths, baseline); + const torn = baseline.events + suffix.slice(0, Math.max(1, Math.floor(suffix.length / 3))); + fs.writeFileSync(paths.events, torn, 'utf8'); + + const recovered = loadCanonicalEvents(rootDir); + assert.equal(recovered.length, 1); + assert.equal(fs.readFileSync(paths.events, 'utf8'), baseline.events); + assert.equal(fs.existsSync(paths.pending), false); +}); + +test('V13-LEDGER-02 complete valid final event missing only newline is preserved, not truncated', (t) => { + const rootDir = tempDir(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const baseline = captureMaterialized(paths); + + appendMetadataEvent({ rootDir, eventType: 'SECOND_EVENT', payload: { value: 2 } }); + const full = fs.readFileSync(paths.events, 'utf8'); + const suffix = full.slice(baseline.events.length); + assert.ok(suffix.endsWith('\n')); + + restoreMaterialized(paths, baseline); + fs.writeFileSync(paths.events, baseline.events + suffix.slice(0, -1), 'utf8'); + + const recovered = loadCanonicalEvents(rootDir); + assert.equal(recovered.length, 2); + assert.ok(fs.readFileSync(paths.events, 'utf8').endsWith('\n')); + + loadStateSnapshot(rootDir, { rebuildIfNeeded: true }); + rebuildStateIndex(rootDir); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('V13-LEDGER-03 acknowledged snapshot prevents truncating a torn tail that may contain accepted history', (t) => { + const rootDir = tempDir(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const baselineEvents = fs.readFileSync(paths.events, 'utf8'); + + appendMetadataEvent({ rootDir, eventType: 'ACKNOWLEDGED_EVENT', payload: { value: 2 } }); + const full = fs.readFileSync(paths.events, 'utf8'); + const suffix = full.slice(baselineEvents.length); + const torn = baselineEvents + suffix.slice(0, Math.max(1, Math.floor(suffix.length / 3))); + fs.writeFileSync(paths.events, torn, 'utf8'); + if (fs.existsSync(paths.pending)) fs.rmSync(paths.pending, { force: true }); + + assert.throws( + () => loadCanonicalEvents(rootDir), + /Refusing to truncate trailing canonical bytes witnessed by the materialized snapshot/, + ); + assert.equal(fs.readFileSync(paths.events, 'utf8'), torn); +}); + +test('V13-LEDGER-04 parseable hash-invalid final event fails closed even without pending journal', (t) => { + const rootDir = tempDir(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const baseline = captureMaterialized(paths); + + appendMetadataEvent({ rootDir, eventType: 'SECOND_EVENT', payload: { value: 2 } }); + const full = fs.readFileSync(paths.events, 'utf8'); + const line = full.slice(baseline.events.length).trim(); + const tampered = JSON.parse(line); + tampered.payload = { value: 999 }; + + restoreMaterialized(paths, baseline); + const bytes = baseline.events + JSON.stringify(tampered); + fs.writeFileSync(paths.events, bytes, 'utf8'); + + assert.throws(() => loadCanonicalEvents(rootDir), /hash mismatch/i); + assert.equal(fs.readFileSync(paths.events, 'utf8'), bytes); +}); + +test('V13-LEDGER-05 mid-ledger corruption remains fail-closed', (t) => { + const rootDir = tempDir(t); + appendMetadataEvent({ rootDir, eventType: 'ONE', payload: { value: 1 } }); + appendMetadataEvent({ rootDir, eventType: 'TWO', payload: { value: 2 } }); + appendMetadataEvent({ rootDir, eventType: 'THREE', payload: { value: 3 } }); + const paths = getStateEnginePaths(rootDir); + const lines = fs.readFileSync(paths.events, 'utf8').trimEnd().split('\n'); + lines[1] = '{corrupt-middle'; + const corrupted = lines.join('\n') + '\n'; + fs.writeFileSync(paths.events, corrupted, 'utf8'); + + assert.throws(() => loadCanonicalEvents(rootDir), /JSON is invalid at line 2/i); + assert.equal(fs.readFileSync(paths.events, 'utf8'), corrupted); +}); + +test('W73-01/W73-04 normal restore retains hash-chained terminal transaction evidence', (t) => { + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-restore-parent-'); + const targetRoot = path.join(parent, 'restored-project'); + + const result = restoreLegacyBackup({ + backupPath, + targetRoot, + overwrite: true, + confirmOffline: true, + }); + + assert.equal(fs.existsSync(result.transactionLog), true); + assert.equal(fs.existsSync(targetRoot), true); + const lines = fs.readFileSync(result.transactionLog, 'utf8').trimEnd().split('\n').map(JSON.parse); + assert.deepEqual( + lines.map((record) => record.phase), + ['PREPARED', 'CANDIDATE_PUBLISHED', 'PROMOTION_ACKNOWLEDGED', 'CLEANUP_SAFE'], + ); + assert.ok(lines.every((record) => /^sha256:[a-f0-9]{64}$/.test(record.recordHash))); + assert.equal(lines.at(-1).outcome, 'promoted-new-root'); + + const recovery = recoverLegacyRestore({ targetRoot, confirmOffline: true }); + assert.equal(recovery.alreadyComplete, true); + assert.equal(recovery.transactionId, result.transactionId); +}); + +test('W73-09 a second restore appends a new transaction without deleting prior recovery history', (t) => { + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-restore-repeat-'); + const targetRoot = path.join(parent, 'restored-project'); + + const first = restoreLegacyBackup({ backupPath, targetRoot, overwrite: true, confirmOffline: true }); + const firstLog = fs.readFileSync(first.transactionLog, 'utf8'); + + const second = restoreLegacyBackup({ backupPath, targetRoot, overwrite: true, confirmOffline: true }); + assert.equal(second.transactionLog, first.transactionLog); + const secondLog = fs.readFileSync(second.transactionLog, 'utf8'); + assert.ok(secondLog.startsWith(firstLog)); + + const records = secondLog.trimEnd().split('\n').map(JSON.parse); + const transactions = [...new Set(records.map((record) => record.transactionId))]; + assert.equal(transactions.length, 2); + assert.equal(records.at(-1).phase, 'CLEANUP_SAFE'); +}); + +test('W73-07 missing journal with a surviving staging witness fails closed instead of guessing', (t) => { + const parent = tempDir(t, 'dk-v013-orphan-'); + const targetRoot = path.join(parent, 'project'); + const orphan = path.join(parent, '.dk-legacy-restore-stage-' + 'a'.repeat(32)); + fs.mkdirSync(orphan); + + assert.throws( + () => recoverLegacyRestore({ targetRoot, confirmOffline: true }), + /witnesses exist without a recovery journal/i, + ); + assert.equal(fs.existsSync(orphan), true); +}); + +test('W73-10 no transaction and no witness reports no recovery work', (t) => { + const parent = tempDir(t, 'dk-v013-empty-recovery-'); + const targetRoot = path.join(parent, 'project'); + assert.deepEqual( + recoverLegacyRestore({ targetRoot, confirmOffline: true }), + { recovered: false }, + ); +}); + + +test('W73-11 terminal restore log cannot certify a missing promoted root', (t) => { + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-terminal-witness-'); + const targetRoot = path.join(parent, 'restored-project'); + const result = restoreLegacyBackup({ backupPath, targetRoot, overwrite: true, confirmOffline: true }); + assert.equal(fs.existsSync(result.transactionLog), true); + assert.equal(fs.existsSync(targetRoot), true); + + // Model a persisted terminal record whose corresponding directory rename + // was not durable across a power interruption. No attacker is assumed. + // A terminal record must never be sufficient to declare recovery complete. + const moved = path.join(parent, 'simulated-unpersisted-promotion'); + fs.renameSync(targetRoot, moved); + assert.throws( + () => recoverLegacyRestore({ targetRoot, confirmOffline: true }), + /terminal.*(root|witness)|terminal.*identity/i, + 'Missing promoted root must fail closed despite a valid terminal journal', + ); + assert.equal(fs.existsSync(moved), true, 'Do not destroy recovery evidence'); + assert.equal(fs.existsSync(result.transactionLog), true, 'Retain authenticated journal'); +}); + + +test('W73-12 completed Windows-compatible restore retains exact original inode after terminal acknowledgement', (t) => { + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-retained-original-'); + const targetRoot = path.join(parent, 'project'); + fs.mkdirSync(targetRoot); + const originalSentinel = path.join(targetRoot, 'original-witness.txt'); + fs.writeFileSync(originalSentinel, 'ORIGINAL-MUST-SURVIVE'); + const before = fs.lstatSync(targetRoot, { bigint: true }); + + const restored = restoreLegacyBackup({ + backupPath, targetRoot, overwrite: true, confirmOffline: true, + }); + assert.ok(restored.displacedOriginal, + 'The original directory must remain an independently verifiable rollback witness'); + const retained = fs.lstatSync(restored.displacedOriginal, { bigint: true }); + assert.equal(retained.dev, before.dev); + assert.equal(retained.ino, before.ino, 'Original directory inode must be retained'); + assert.equal(fs.readFileSync(path.join(restored.displacedOriginal, 'original-witness.txt'), 'utf8'), + 'ORIGINAL-MUST-SURVIVE'); + assert.equal(fs.existsSync(restored.transactionLog), true, 'Retain terminal transaction log'); + const recovered = recoverLegacyRestore({ targetRoot, confirmOffline: true }); + assert.equal(recovered.alreadyComplete, true); + assert.equal(fs.existsSync(restored.displacedOriginal), true, + 'Read-only completed recovery must not delete the original witness'); +}); + + +for (const linkKind of ['symbolic', 'hard']) { + test(`W73-13/${linkKind} pre-existing linked restore journal cannot redirect transaction writes`, (t) => { + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-journal-link-'); + const targetRoot = path.join(parent, 'project'); + const outside = path.join(parent, 'unrelated-confidential-log.jsonl'); + fs.writeFileSync(outside, ''); + const digest = crypto.createHash('sha256').update(path.resolve(targetRoot)).digest('hex').slice(0, 24); + const journal = path.join(parent, '.dk-restore-transaction-' + digest + '.jsonl'); + try { + if (linkKind === 'symbolic') fs.symlinkSync(outside, journal, 'file'); + else fs.linkSync(outside, journal); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOTSUP', 'EOPNOTSUPP'].includes(error.code)) { + t.skip('Host does not permit this linked-file fixture'); return; + } + throw error; + } + assert.throws( + () => restoreLegacyBackup({ backupPath, targetRoot, confirmOffline: true }), + /restore.*(journal|transaction|link)|unsafe.*(link|journal)/i, + 'Existing linked journal must fail closed before writing external bytes', + ); + assert.equal(fs.readFileSync(outside, 'utf8'), '', 'Unrelated target must remain untouched'); + assert.equal(fs.existsSync(targetRoot), false); + }); +} + + +test('W73-14 retained original from one project must not block unrelated target in same parent', (t) => { + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-independent-targets-'); + const firstRoot = path.join(parent, 'first'); + const secondRoot = path.join(parent, 'second'); + fs.mkdirSync(firstRoot); + fs.writeFileSync(path.join(firstRoot, 'first-original.txt'), 'FIRST-ORIGINAL'); + const first = restoreLegacyBackup({ backupPath, targetRoot: firstRoot, overwrite: true, confirmOffline: true }); + assert.ok(first.displacedOriginal); + assert.equal(fs.existsSync(first.displacedOriginal), true); + + const second = restoreLegacyBackup({ backupPath, targetRoot: secondRoot, confirmOffline: true }); + assert.equal(fs.existsSync(secondRoot), true, + 'Unrelated target must remain independently restorable'); + assert.equal(fs.existsSync(second.transactionLog), true); + assert.equal(fs.readFileSync(path.join(first.displacedOriginal, 'first-original.txt'), 'utf8'), + 'FIRST-ORIGINAL', 'Original rollback witness must survive the other restore'); +}); + + +test('W73-15 a terminal journal with a missing root blocks the next restore without superseding evidence', (t) => { + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-terminal-preflight-'); + const targetRoot = path.join(parent, 'project'); + fs.mkdirSync(targetRoot); + fs.writeFileSync(path.join(targetRoot, 'original.txt'), 'KEEP-ORIGINAL'); + const first = restoreLegacyBackup({ backupPath, targetRoot, overwrite: true, confirmOffline: true }); + assert.ok(first.displacedOriginal); + const before = fs.readFileSync(first.transactionLog, 'utf8'); + + // Model terminal journal persistence without the preceding root publication. + const unpersisted = path.join(parent, 'unpersisted-published-root'); + fs.renameSync(targetRoot, unpersisted); + assert.throws( + () => restoreLegacyBackup({ backupPath, targetRoot, confirmOffline: true }), + /terminal.*(root|witness)|terminal.*identity/i, + 'A newer restore must not supersede an unresolved terminal root witness', + ); + assert.equal(fs.existsSync(targetRoot), false); + assert.equal(fs.readFileSync(first.transactionLog, 'utf8'), before); + assert.equal(fs.readFileSync(path.join(first.displacedOriginal, 'original.txt'), 'utf8'), 'KEEP-ORIGINAL'); +}); + +test('W73-16 imported V1 terminal outcomes remain idempotently recoverable', (t) => { + const parent = tempDir(t, 'dk-v013-imported-v1-'); + const targetRoot = path.join(parent, 'project'); + fs.mkdirSync(targetRoot); + fs.writeFileSync(path.join(targetRoot, 'original.txt'), 'V1-ORIGINAL'); + const original = fs.lstatSync(targetRoot, { bigint: true }); + const transactionId = 'a'.repeat(32); + const stageName = '.dk-legacy-restore-stage-' + transactionId; + const digest = crypto.createHash('sha256').update(path.resolve(targetRoot)).digest('hex').slice(0, 24); + const journal = path.join(parent, '.dk-restore-journal-' + digest + '.json'); + fs.writeFileSync(journal, JSON.stringify({ + schemaVersion: '1.0.0', transactionId, targetRoot, stageName, + existed: true, originalDev: original.dev.toString(), originalIno: original.ino.toString(), + stageDev: '1', stageIno: '1', + })); + const first = recoverLegacyRestore({ targetRoot, confirmOffline: true }); + assert.equal(first.legacyJournal, true); + assert.equal(first.outcome, 'uncommitted-original-unchanged'); + assert.equal(fs.existsSync(first.retainedJournal), true); + const second = recoverLegacyRestore({ targetRoot, confirmOffline: true }); + assert.equal(second.alreadyComplete, true); + assert.equal(fs.readFileSync(path.join(targetRoot, 'original.txt'), 'utf8'), 'V1-ORIGINAL'); +}); + +test('W73-17 mixed legacy Autopilot and canonical V2 orchestration runs migrate without revision regression', (t) => { + const rootDir = tempDir(t, 'dk-v013-mixed-state-'); + const legacy = createInitialState({ autonomy: 'guided-autopilot' }, rootDir); + const legacyDir = path.join(rootDir, '.development-kit', 'autopilot', 'state'); + fs.mkdirSync(legacyDir, { recursive: true }); + fs.writeFileSync(path.join(legacyDir, 'revision-000001.json'), JSON.stringify(legacy, null, 2)); + saveStateRevision(legacy, rootDir); + + const run = { + schemaVersion: '1.0.0', contractId: 'contract-mixed', taskId: 'task-mixed', + runId: 'run-mixed', sourceFingerprint: 'sha256:' + 'a'.repeat(64), + createdAt: '2026-10-30T00:00:00.000Z', updatedAt: '2026-10-30T00:00:00.000Z', + stateRevision: 1, state: 'READY', executionStrategy: 'direct', + hostCapabilities: {}, manualEvidenceRequired: false, requiredGates: {}, + completedGates: [], correctionAttempt: 0, failureSignatures: [], + verificationVerdict: null, acceptanceState: 'PENDING', + }; + persistRunManifest(run, rootDir); + assert.equal(persistRunStateRevision(run, rootDir).stateEngine, 'v2'); + const revised = updateRun(run, { state: 'IMPLEMENTING' }, '2026-10-30T00:01:00.000Z'); + assert.equal(persistRunStateRevision(revised, rootDir).stateEngine, 'v2'); + const migration = migrateLegacyStateToV2({ rootDir, completedAt: '2026-10-30T00:02:00.000Z' }); + assert.equal(migration.semanticEquivalence, true); + assert.equal(loadEntityState('orchestration-run', 'contract-mixed/run-mixed', rootDir).stateRevision, 2); +}); + + +test('W73-18 recovery cannot acknowledge candidate promotion without retrying parent flush', (t) => { + if (process.platform === 'win32') { + t.skip('POSIX fsync injection; Windows native-helper barrier requires its own fault hook'); return; + } + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-publication-barrier-'); + const targetRoot = path.join(parent, 'project'); + fs.mkdirSync(targetRoot); + fs.writeFileSync(path.join(targetRoot, 'original.txt'), 'RETAIN-ORIGINAL'); + const originalRename = fs.renameSync; + const originalFsync = fs.fsyncSync; + const parentStat = fs.statSync(parent); + let promoted = false; + let injected = false; + fs.renameSync = function watchPromotion(from, to, ...args) { + const result = originalRename.call(this, from, to, ...args); + if (typeof from === 'string' && from.includes('.dk-legacy-restore-stage-') && + !from.endsWith('-original') && to === targetRoot) promoted = true; + return result; + }; + fs.fsyncSync = function failPublicationBarrier(fd) { + const stat = fs.fstatSync(fd); + if (promoted && !injected && stat.isDirectory() && + stat.dev === parentStat.dev && stat.ino === parentStat.ino) { + injected = true; + throw new Error('simulated publication parent flush failure'); + } + return originalFsync.call(this, fd); + }; + try { + assert.throws( + () => restoreLegacyBackup({ backupPath, targetRoot, overwrite: true, confirmOffline: true }), + /simulated publication parent flush failure|flush barrier failed/i, + ); + assert.equal(injected, true); + } finally { + fs.renameSync = originalRename; + fs.fsyncSync = originalFsync; + } + const journal = fs.readdirSync(parent).find((name) => name.startsWith('.dk-restore-transaction-')); + assert.ok(journal); + const journalPath = path.join(parent, journal); + assert.equal(fs.readFileSync(journalPath, 'utf8').includes('"phase":"CLEANUP_SAFE"'), false); + + let recoveryBarrierAttempted = false; + fs.fsyncSync = function failRecoveryBarrier(fd) { + const stat = fs.fstatSync(fd); + if (!recoveryBarrierAttempted && stat.isDirectory() && + stat.dev === parentStat.dev && stat.ino === parentStat.ino) { + recoveryBarrierAttempted = true; + throw new Error('simulated recovery publication flush failure'); + } + return originalFsync.call(this, fd); + }; + try { + assert.throws( + () => recoverLegacyRestore({ targetRoot, confirmOffline: true }), + /simulated recovery publication flush failure|flush barrier failed/i, + ); + assert.equal(recoveryBarrierAttempted, true, 'Recovery must retry the publication flush'); + assert.equal(fs.readFileSync(journalPath, 'utf8').includes('"phase":"CLEANUP_SAFE"'), false, + 'No terminal acknowledgement after a failed recovery barrier'); + } finally { + fs.fsyncSync = originalFsync; + } + const recovered = recoverLegacyRestore({ targetRoot, confirmOffline: true }); + assert.equal(recovered.outcome, 'promoted-original-retained'); + assert.equal(fs.existsSync(recovered.displacedOriginal), true); +}); + + +test('W73-19 Windows case alias cannot bypass the existing restore transaction witness', (t) => { + if (process.platform !== 'win32') { + t.skip('NTFS case-insensitive alias behavior requires Windows'); return; + } + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-ntfs-case-alias-'); + const targetRoot = path.join(parent, 'MixedCaseProject'); + const aliasRoot = path.join(parent, 'mixedcaseproject'); + fs.mkdirSync(targetRoot); + fs.writeFileSync(path.join(targetRoot, 'original.txt'), 'ORIGINAL-CASE-ALIAS'); + const first = restoreLegacyBackup({ backupPath, targetRoot, overwrite: true, confirmOffline: true }); + assert.ok(first.displacedOriginal); + const journalBefore = fs.readFileSync(first.transactionLog, 'utf8'); + const moved = path.join(parent, 'unpersisted-published-root'); + fs.renameSync(targetRoot, moved); + assert.throws( + () => restoreLegacyBackup({ backupPath, targetRoot: aliasRoot, confirmOffline: true }), + /terminal.*(root|witness)|incomplete restore|restore witnesses/i, + 'Case alias must not start a new transaction over an unresolved original', + ); + assert.equal(fs.readFileSync(first.transactionLog, 'utf8'), journalBefore); + assert.equal(fs.existsSync(aliasRoot), false); + assert.equal(fs.readFileSync(path.join(first.displacedOriginal, 'original.txt'), 'utf8'), + 'ORIGINAL-CASE-ALIAS'); +}); + + +test('W73-20 Windows case alias cannot bypass an orphan displaced original after journal loss', (t) => { + if (process.platform !== 'win32') { + t.skip('NTFS case-insensitive alias behavior requires Windows'); return; + } + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-orphan-case-alias-'); + const targetRoot = path.join(parent, 'OriginalCaseProject'); + const aliasRoot = path.join(parent, 'originalcaseproject'); + fs.mkdirSync(targetRoot); + fs.writeFileSync(path.join(targetRoot, 'original.txt'), 'ORIGINAL-ORPHAN'); + const first = restoreLegacyBackup({ backupPath, targetRoot, overwrite: true, confirmOffline: true }); + const moved = path.join(parent, 'unpersisted-published-root'); + fs.renameSync(targetRoot, moved); + fs.unlinkSync(first.transactionLog); // Simulate journal namespace loss after an interrupted publication. + assert.throws( + () => restoreLegacyBackup({ backupPath, targetRoot: aliasRoot, confirmOffline: true }), + /restore witnesses|restore.*journal|orphan.*restore|recovery journal/i, + 'An orphan retained original must not be silently superseded via case alias', + ); + assert.equal(fs.existsSync(aliasRoot), false); + assert.equal(fs.readFileSync(path.join(first.displacedOriginal, 'original.txt'), 'utf8'), 'ORIGINAL-ORPHAN'); +}); + + +test('W73-22 Windows native helper must prove operation-specific success before advancing recovery', () => { + const probe = { + path: 'C:\\dk\\parent', volumeRoot: 'C:\\', fileSystem: 'NTFS', + driveType: 3, localFixed: true, supportedAutomaticRestore: true, + }; + assert.deepEqual(validateWindowsDurabilityHelperResult('Probe', probe), probe); + assert.throws(() => validateWindowsDurabilityHelperResult('Probe', { + ...probe, supportedAutomaticRestore: false, + }), /unsupported|profile|probe/i); + + const flush = { operation: 'FlushDirectory', profile: probe, flushed: true }; + assert.deepEqual(validateWindowsDurabilityHelperResult('FlushDirectory', flush), flush); + for (const bad of [ + { ...flush, flushed: false }, + { ...flush, flushed: undefined }, + { ...flush, operation: 'Rename' }, + { ...flush, profile: { ...probe, supportedAutomaticRestore: false } }, + ]) assert.throws(() => validateWindowsDurabilityHelperResult('FlushDirectory', bad), + /helper|flush|profile|operation/i); + + const rename = { + operation: 'Rename', source: 'C:\\dk\\from', destination: 'C:\\dk\\to', + profile: probe, renamed: true, parentFlushCompleted: true, + }; + assert.deepEqual(validateWindowsDurabilityHelperResult('Rename', rename), rename); + for (const bad of [ + { ...rename, renamed: false }, + { ...rename, renamed: undefined }, + { ...rename, parentFlushCompleted: false }, + { ...rename, parentFlushCompleted: undefined }, + { ...rename, operation: 'Probe' }, + { ...rename, profile: { ...probe, fileSystem: 'ReFS' } }, + ]) assert.throws(() => validateWindowsDurabilityHelperResult('Rename', bad), + /helper|rename|profile|operation|flush/i); +}); + +test('W73-25 native Windows helper cannot acknowledge an unrelated filesystem path', () => { + const profile = { + path: 'C:\\dk\\parent', volumeRoot: 'C:\\', fileSystem: 'NTFS', + driveType: 3, localFixed: true, supportedAutomaticRestore: true, + }; + assert.deepEqual( + validateWindowsDurabilityHelperResult('Probe', profile, { Path: profile.path }), + profile, + ); + assert.throws( + () => validateWindowsDurabilityHelperResult('Probe', { + ...profile, path: 'C:\\dk\\unrelated', + }, { Path: profile.path }), + /helper|path|profile|mismatch/i, + 'A supported NTFS profile for a different path is not evidence for the requested root', + ); + + const flush = { operation: 'FlushDirectory', profile, flushed: true }; + assert.deepEqual( + validateWindowsDurabilityHelperResult('FlushDirectory', flush, { Path: profile.path }), + flush, + ); + assert.throws( + () => validateWindowsDurabilityHelperResult('FlushDirectory', { + ...flush, profile: { ...profile, path: 'C:\\dk\\unrelated' }, + }, { Path: profile.path }), + /helper|path|profile|mismatch/i, + 'A flush of a different parent cannot acknowledge the requested namespace barrier', + ); + + const rename = { + operation: 'Rename', source: 'C:\\dk\\stage', destination: 'C:\\dk\\project', + profile: { ...profile, path: 'C:\\dk' }, renamed: true, parentFlushCompleted: true, + }; + assert.deepEqual( + validateWindowsDurabilityHelperResult('Rename', rename, { + From: rename.source, To: rename.destination, + }), + rename, + ); + for (const wrong of [ + { ...rename, source: 'C:\\dk\\unrelated' }, + { ...rename, source: 'C:\\other-parent\\stage' }, + { ...rename, destination: 'C:\\dk\\unrelated' }, + { ...rename, profile: { ...profile, path: 'C:\\dk\\unrelated' } }, + ]) { + assert.throws( + () => validateWindowsDurabilityHelperResult('Rename', wrong, { + From: rename.source, To: rename.destination, + }), + /helper|path|profile|mismatch/i, + 'An unrelated rename or parent flush cannot acknowledge the requested transaction', + ); + } +}); + +test('W73-21 Windows restore stage and promoted root preserve the original restrictive NTFS DACL', (t) => { + if (process.platform !== 'win32') { + t.skip('NTFS ACL fixture requires Windows'); return; + } + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-ntfs-acl-'); + const targetRoot = path.join(parent, 'restricted-project'); + fs.mkdirSync(targetRoot); + fs.writeFileSync(path.join(targetRoot, 'confidential.txt'), 'CONFIDENTIAL-ORIGINAL'); + + function aclScript(script, location) { + return spawnSync('powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + { encoding: 'utf8', windowsHide: true, shell: false, timeout: 30000, + env: { ...process.env, DKF_TEST_ACL_PATH: location } }); + } + const restrict = aclScript([ + "$ErrorActionPreference='Stop'", + '$acl=[System.IO.Directory]::GetAccessControl($env:DKF_TEST_ACL_PATH)', + '$acl.SetAccessRuleProtection($true,$false)', + '$sid=[System.Security.Principal.WindowsIdentity]::GetCurrent().User', + '$rights=[System.Security.AccessControl.FileSystemRights]::FullControl', + '$flags=[System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit', + '$rule=[System.Security.AccessControl.FileSystemAccessRule]::new($sid,$rights,$flags,[System.Security.AccessControl.PropagationFlags]::None,[System.Security.AccessControl.AccessControlType]::Allow)', + '$acl.SetAccessRule($rule)', + '[System.IO.Directory]::SetAccessControl($env:DKF_TEST_ACL_PATH,$acl)', + ].join(';'), targetRoot); + if (restrict.status !== 0) { + t.skip('Host cannot create restrictive NTFS DACL fixture: ' + String(restrict.stderr).slice(0, 200)); + return; + } + function dacl(location) { + const result = aclScript( + "$ErrorActionPreference='Stop'; ([System.IO.Directory]::GetAccessControl($env:DKF_TEST_ACL_PATH)).GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access)", + location, + ); + assert.equal(result.status, 0, String(result.stderr)); + return result.stdout.trim(); + } + const comparableDacl = (value) => value; + const originalDacl = dacl(targetRoot); + const fileDacl = (location) => { + const result = aclScript( + "$ErrorActionPreference='Stop'; ([System.IO.File]::GetAccessControl($env:DKF_TEST_ACL_PATH)).GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access)", + location, + ); + assert.equal(result.status, 0, String(result.stderr)); + return result.stdout.trim(); + }; + const originalFileDacl = fileDacl(path.join(targetRoot, 'confidential.txt')); + assert.notEqual(originalDacl, dacl(parent), 'Fixture must differ from inherited parent DACL'); + + const originalCp = fs.cpSync; + let stageInspected = false; + fs.cpSync = function verifyPrivateStageBeforeCopy(source, destination, ...options) { + if (path.resolve(source) === path.resolve(targetRoot)) { + stageInspected = true; + assert.match(dacl(destination), /^D:P/, + 'Restore stage must be private and protected before sensitive data copy'); + } + return originalCp.call(this, source, destination, ...options); + }; + try { + try { + restoreLegacyBackup({ backupPath, targetRoot, overwrite: true, confirmOffline: true }); + } catch (error) { + assert.fail('W73-21 restore error: ' + error.message + ' details=' + JSON.stringify(error.details)); + } + } finally { + fs.cpSync = originalCp; + } + assert.equal(stageInspected, true); + assert.equal(comparableDacl(dacl(targetRoot)), comparableDacl(originalDacl), + 'Promoted root must preserve source DACL protection and ACEs'); + assert.equal(comparableDacl(fileDacl(path.join(targetRoot, 'confidential.txt'))), + comparableDacl(originalFileDacl), + 'Original file DACL must survive staged copy and promotion'); + assert.equal(fs.readFileSync(path.join(targetRoot, 'confidential.txt'), 'utf8'), 'CONFIDENTIAL-ORIGINAL'); +}); + +test('W73-23 native ACL helper responses must be explicit and path-bound', () => { + const stage = { operation: 'CreateSecureStage', path: 'C:\\dk\\stage', protected: true }; + assert.deepEqual(validateWindowsDurabilityHelperResult('CreateSecureStage', stage, + { Path: 'C:\\dk\\stage' }), stage); + for (const invalid of [ + { ...stage, protected: false }, + { ...stage, path: 'C:\\dk\\other' }, + { ...stage, operation: 'Rename' }, + ]) { + assert.throws(() => validateWindowsDurabilityHelperResult('CreateSecureStage', invalid, + { Path: 'C:\\dk\\stage' }), /helper|stage|path|operation|protect/i); + } + + const tree = { operation: 'CopyAclTree', source: 'C:\\dk\\source', + destination: 'C:\\dk\\stage', verified: true }; + assert.deepEqual(validateWindowsDurabilityHelperResult('CopyAclTree', tree, + { From: 'C:\\dk\\source', To: 'C:\\dk\\stage' }), tree); + for (const invalid of [ + { ...tree, verified: false }, + { ...tree, source: 'C:\\dk\\wrong' }, + { ...tree, destination: 'C:\\dk\\wrong' }, + { ...tree, operation: 'Rename' }, + ]) { + assert.throws(() => validateWindowsDurabilityHelperResult('CopyAclTree', invalid, + { From: 'C:\\dk\\source', To: 'C:\\dk\\stage' }), + /helper|acl|path|operation|source|destination/i); + } +}); + + +test('W73-24 staged confidential child is never copied under a broader root DACL', (t) => { + if (process.platform !== 'win32') { + t.skip('NTFS DACL isolation requires Windows'); return; + } + const { backupPath } = createLegacyBackupFixture(t); + const parent = tempDir(t, 'dk-v013-ntfs-private-stage-'); + const targetRoot = path.join(parent, 'mixed-access-project'); + const child = path.join(targetRoot, 'confidential.txt'); + fs.mkdirSync(targetRoot); + fs.writeFileSync(child, 'CONFIDENTIAL-ORIGINAL'); + + function ps(script, location) { + return spawnSync('powershell.exe', + ['-NoLogo', '-NoProfile', '-NonInteractive', '-Command', script], + { encoding: 'utf8', windowsHide: true, shell: false, timeout: 30000, + env: { ...process.env, DKF_TEST_ACL_PATH: location } }); + } + const aclSetup = ps([ + "$ErrorActionPreference='Stop'", + '$root=$env:DKF_TEST_ACL_PATH', + '$user=[System.Security.Principal.WindowsIdentity]::GetCurrent().User', + '$everyone=[System.Security.Principal.SecurityIdentifier]::new("S-1-1-0")', + '$full=[System.Security.AccessControl.FileSystemRights]::FullControl', + '$read=[System.Security.AccessControl.FileSystemRights]::ReadAndExecute', + '$inherit=[System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor [System.Security.AccessControl.InheritanceFlags]::ObjectInherit', + '$allow=[System.Security.AccessControl.AccessControlType]::Allow', + '$none=[System.Security.AccessControl.PropagationFlags]::None', + '$acl=[System.IO.Directory]::GetAccessControl($root)', + '$acl.SetAccessRuleProtection($true,$false)', + '$acl.SetAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($user,$full,$inherit,$none,$allow))', + '$acl.AddAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($everyone,$read,$inherit,$none,$allow))', + '[System.IO.Directory]::SetAccessControl($root,$acl)', + '$file=[System.IO.File]::GetAccessControl([System.IO.Path]::Combine($root,"confidential.txt"))', + '$file.SetAccessRuleProtection($true,$false)', + '$file.SetAccessRule([System.Security.AccessControl.FileSystemAccessRule]::new($user,$full,$allow))', + '[System.IO.File]::SetAccessControl([System.IO.Path]::Combine($root,"confidential.txt"),$file)', + ].join(';'), targetRoot); + assert.equal(aclSetup.status, 0, 'Windows DACL fixture must execute: ' + String(aclSetup.stderr)); + function dacl(location, kind = 'Directory') { + const z = ps("$ErrorActionPreference='Stop'; ([System.IO." + kind + + "]::GetAccessControl($env:DKF_TEST_ACL_PATH)).GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access)", + location); + assert.equal(z.status, 0, String(z.stderr)); + return z.stdout.trim(); + } + const rootAcl = dacl(targetRoot); + const childAcl = dacl(child, 'File'); + assert.match(rootAcl, /;;;WD\)/, 'Fixture root must grant Everyone read access'); + assert.doesNotMatch(childAcl, /;;;WD\)/, 'Fixture confidential child must deny Everyone access'); + + const originalCp = fs.cpSync; + let inspected = false; + fs.cpSync = function assertPrivateStageDuringCopy(source, destination, ...args) { + if (path.resolve(source) === path.resolve(targetRoot)) { + inspected = true; + const before = dacl(destination); + assert.match(before, /^D:P/, 'Stage root must have protected private DACL before copy'); + assert.doesNotMatch(before, /;;;WD\)/, 'Stage root must not expose confidential children'); + const result = originalCp.call(this, source, destination, ...args); + const during = dacl(destination); + assert.match(during, /^D:P/, 'Stage must remain private until child ACLs are restored'); + assert.doesNotMatch(during, /;;;WD\)/, 'Copied confidential bytes must remain private'); + return result; + } + return originalCp.call(this, source, destination, ...args); + }; + try { + restoreLegacyBackup({ backupPath, targetRoot, overwrite: true, confirmOffline: true }); + } finally { + fs.cpSync = originalCp; + } + assert.equal(inspected, true, 'Test must observe the real copy boundary'); + assert.equal(dacl(targetRoot), rootAcl, 'Published root must preserve the original DACL'); + assert.equal(dacl(path.join(targetRoot, 'confidential.txt'), 'File'), childAcl, + 'Published confidential child must preserve its more restrictive DACL'); +}); + +test('V13-API-PROJECTION-01 scale-context observes canonical V2 workflow without a legacy pointer', async (t) => { + const rootDir = tempDir(t, 'dk-v013-control-center-v2-'); + const state = createInitialState({ autonomy: 'guided-autopilot' }, rootDir); + saveStateRevision(state, rootDir); + const legacyPointer = path.join(rootDir, '.development-kit', 'autopilot', 'state', 'current.json'); + assert.equal(fs.existsSync(legacyPointer), false, + 'Fresh V2 projects must not fabricate a legacy pointer for UI compatibility'); + const service = new RuntimeApiService({ rootDir }); + const info = await service.start(); + try { + const get = async route => { + const response = await fetch(info.url + route); + assert.equal(response.status, 200, route); + return response.json(); + }; + const workflow = await get('/v1/workflow'); + assert.equal(workflow.state?.workflowId, state.workflowId); + const projection = await get('/v1/scale-context'); + assert.equal(projection.state.active, true, + 'Scale & Context must not report inactive while canonical V2 workflow is active'); + assert.equal(projection.state.currentStage, state.currentStage); + assert.equal(projection.state.revision, state.stateRevision); + assert.equal(projection.canonicalStateStore, 'state-engine-v2-event-ledger'); + assert.equal(projection.capabilities.stateEngineV2.installed, true); + assert.equal(projection.capabilities.stateEngineV2.accepted, null, + 'Installed and active is not independently ACCEPTED'); + assert.equal(fs.existsSync(legacyPointer), false, + 'Read-only projection must not create or mutate legacy authority'); + } finally { + await service.stop(); + } +}); + +test('V13-API-PROJECTION-02 read-only projection never repairs unmigrated legacy pointer under V2 metadata', async (t) => { + const rootDir = tempDir(t, 'dk-v013-api-legacy-pointer-'); + const legacyDir = path.join(rootDir, '.development-kit', 'autopilot', 'state'); + fs.mkdirSync(legacyDir, { recursive: true }); + const legacy = createInitialState({ autonomy: 'guided-autopilot' }, rootDir); + const revisionFile = 'revision-' + String(legacy.stateRevision).padStart(6, '0') + '.json'; + fs.writeFileSync(path.join(legacyDir, revisionFile), JSON.stringify(legacy)); + const pointerPath = path.join(legacyDir, 'current.json'); + const invalidPointer = '{"invalid":'; + fs.writeFileSync(pointerPath, invalidPointer); + appendMetadataEvent({ rootDir, eventType: 'UNRELATED_METADATA', payload: { note: 'unmigrated' } }); + const paths = getStateEnginePaths(rootDir); + const originalEvents = fs.readFileSync(paths.events, 'utf8'); + + const service = new RuntimeApiService({ rootDir }); + const info = await service.start(); + try { + const response = await fetch(info.url + '/v1/scale-context'); + assert.equal(response.status, 200); + const projection = await response.json(); + assert.equal(projection.stateHealth, 'degraded', + 'Corrupt legacy pointer must be reported, not silently repaired'); + assert.equal(projection.state.active, false); + assert.equal(fs.readFileSync(pointerPath, 'utf8'), invalidPointer, + 'Read-only projection must preserve the exact corrupt pointer evidence'); + assert.equal(fs.readFileSync(paths.events, 'utf8'), originalEvents, + 'Read-only projection must not modify canonical V2 events'); + } finally { + await service.stop(); + } +}); + +test('V13-API-PROJECTION-03 V2 metadata alone does not hide valid unmigrated legacy workflow', async (t) => { + const rootDir = tempDir(t, 'dk-v013-api-legacy-valid-'); + const legacyDir = path.join(rootDir, '.development-kit', 'autopilot', 'state'); + fs.mkdirSync(legacyDir, { recursive: true }); + const legacy = createInitialState({ autonomy: 'guided-autopilot' }, rootDir); + const revisionFile = 'revision-' + String(legacy.stateRevision).padStart(6, '0') + '.json'; + fs.writeFileSync(path.join(legacyDir, revisionFile), JSON.stringify(legacy)); + const pointerPath = path.join(legacyDir, 'current.json'); + const pointer = JSON.stringify({ currentRevision: legacy.stateRevision, + currentRevisionFile: revisionFile, workflowId: legacy.workflowId }); + fs.writeFileSync(pointerPath, pointer); + appendMetadataEvent({ rootDir, eventType: 'UNRELATED_METADATA', payload: { note: 'unmigrated' } }); + const service = new RuntimeApiService({ rootDir }); + const info = await service.start(); + try { + const response = await fetch(info.url + '/v1/scale-context'); + assert.equal(response.status, 200); + const projection = await response.json(); + assert.equal(projection.state.active, true); + assert.equal(projection.state.revision, legacy.stateRevision); + assert.equal(fs.readFileSync(pointerPath, 'utf8'), pointer); + } finally { + await service.stop(); + } +}); + + +/** + * W73-8DOT3: Use the actual Windows GetShortPathNameW API. These fixtures + * skip only when the test volume does not generate 8.3 aliases; a synthetic + * string-only alias would not prove NTFS directory identity. + */ +function getExistingNtfsShortPath(directory) { + const script = [ + "$ErrorActionPreference='Stop'", + "$code='using System; using System.Text; using System.Runtime.InteropServices; public static class DkfShortPath { [DllImport(\"kernel32.dll\", CharSet=CharSet.Unicode, SetLastError=true)] public static extern uint GetShortPathName(string input, StringBuilder output, uint length); }'", + 'Add-Type -TypeDefinition $code', + '$buffer = New-Object System.Text.StringBuilder(32768)', + '$size = [DkfShortPath]::GetShortPathName($env:DKF_TEST_SHORT_PATH, $buffer, [uint32]$buffer.Capacity)', + 'if ($size -eq 0 -or $size -ge $buffer.Capacity) { exit 2 }', + '[Console]::Out.Write($buffer.ToString())', + ].join('; '); + const result = spawnSync('powershell.exe', + ['-NoProfile', '-NonInteractive', '-Command', script], + { encoding: 'utf8', windowsHide: true, shell: false, timeout: 20000, + env: { ...process.env, DKF_TEST_SHORT_PATH: directory } }); + return result.status === 0 ? result.stdout.trim() : null; +} + +test('W73-8DOT3-01 NTFS ancestor short alias cannot bypass a retained transaction journal', (t) => { + if (process.platform !== 'win32') { t.skip('Requires NTFS Windows'); return; } + const { backupPath } = createLegacyBackupFixture(t); + const container = tempDir(t, 'dk-v013-83-journal-'); + const parent = path.join(container, 'LongDirectoryNameForAlias'); + fs.mkdirSync(parent); + const shortParent = getExistingNtfsShortPath(parent); + if (!shortParent || !path.basename(shortParent).includes('~')) { + t.skip('NTFS 8.3 names are not enabled on this volume'); return; + } + const targetRoot = path.join(parent, 'project'); + const aliasRoot = path.join(shortParent, 'project'); + fs.mkdirSync(targetRoot); + fs.writeFileSync(path.join(targetRoot, 'original.txt'), 'ORIGINAL-83-WITNESS'); + const restored = restoreLegacyBackup({ + backupPath, targetRoot, overwrite: true, confirmOffline: true, + }); + const journalBefore = fs.readFileSync(restored.transactionLog); + const displacedPublication = path.join(parent, 'lost-publication'); + fs.renameSync(targetRoot, displacedPublication); + assert.throws(() => restoreLegacyBackup({ + backupPath, targetRoot: aliasRoot, overwrite: true, confirmOffline: true, + }), /restore.*(alias|witness|transaction|incomplete)|terminal.*root/i); + assert.deepEqual(fs.readFileSync(restored.transactionLog), journalBefore, + 'Short-name retry must not append or rewrite the existing hash-chained journal'); + assert.equal(fs.existsSync(targetRoot), false); + assert.equal(fs.readFileSync(path.join(restored.displacedOriginal, 'original.txt'), 'utf8'), + 'ORIGINAL-83-WITNESS'); +}); + +test('W73-8DOT3-02 signed long/short destination aliases cannot overwrite staged backup bytes', (t) => { + if (process.platform !== 'win32') { t.skip('Requires NTFS Windows'); return; } + const container = tempDir(t, 'dk-v013-83-stage-'); + const probe = path.join(container, 'LongDirectoryNameForAlias'); + fs.mkdirSync(probe); + const shortProbe = getExistingNtfsShortPath(probe); + if (!shortProbe || !path.basename(shortProbe).includes('~')) { + t.skip('NTFS 8.3 names are not enabled on this volume'); return; + } + const shortLeaf = path.basename(shortProbe); + fs.rmdirSync(probe); + const makeEntry = (relative, content) => { + const bytes = Buffer.from(content); + return { + path: relative, bytes: bytes.length, contentBase64: bytes.toString('base64'), + fingerprint: 'sha256:' + crypto.createHash('sha256').update(bytes).digest('hex'), + }; + }; + const files = [ + makeEntry('LongDirectoryNameForAlias/file.json', 'FIRST-SIGNED-BYTES'), + makeEntry(shortLeaf + '/file.json', 'SECOND-SIGNED-BYTES'), + ]; + const manifest = files.map(({ path: name, bytes, fingerprint }) => ({ + bytes, fingerprint, path: name, + })); + const bundle = { + schemaVersion: '1.0.0', fileCount: files.length, files, + sourceFingerprint: 'sha256:' + + crypto.createHash('sha256').update(JSON.stringify(manifest)).digest('hex'), + }; + const backupPath = path.join(container, 'signed-backup.json'); + fs.writeFileSync(backupPath, JSON.stringify(bundle)); + const targetRoot = path.join(container, 'restored'); + assert.throws(() => restoreLegacyBackup({ + backupPath, targetRoot, confirmOffline: true, + }), /Duplicate physical restore destination alias/i, + 'A second signed entry must not replace the same physical staged inode'); + assert.equal(fs.existsSync(targetRoot), false, + 'Rejected alias collision must not publish a candidate root'); + assert.deepEqual(fs.readdirSync(container).filter(name => + name.startsWith('.dk-legacy-restore-stage-')), [], + 'Rejected prepublication stage must be removed without touching any original root'); +}); + + +test('V13-API-PROJECTION-04 interrupted V2 commit must not be recovered by read-only scale-context', async (t) => { + const rootDir = tempDir(t, 'dk-v013-api-pending-readonly-'); + const state = createInitialState({ autonomy: 'guided-autopilot' }, rootDir); + saveStateRevision(state, rootDir); + const paths = getStateEnginePaths(rootDir); + const baseline = captureMaterialized(paths); + + const nextState = { ...state, stateRevision: state.stateRevision + 1, + updatedAt: new Date(Date.parse(state.updatedAt) + 1000).toISOString() }; + saveStateRevision(nextState, rootDir); + const fullEvents = fs.readFileSync(paths.events, 'utf8'); + const suffix = fullEvents.slice(baseline.events.length); + assert.ok(suffix.endsWith('\n') && suffix.length > 0); + + restoreMaterialized(paths, baseline); + const pending = JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: Buffer.byteLength(baseline.events), + previousHash: 'sha256:' + crypto.createHash('sha256').update(baseline.events).digest('hex'), + suffix, + }) + '\n'; + fs.writeFileSync(paths.pending, pending, 'utf8'); + const before = { + events: fs.readFileSync(paths.events), + snapshot: fs.readFileSync(paths.snapshot), + index: fs.readFileSync(paths.index), + pending: fs.readFileSync(paths.pending), + }; + + const service = new RuntimeApiService({ rootDir }); + const info = await service.start(); + try { + const response = await fetch(info.url + '/v1/scale-context'); + assert.equal(response.status, 200); + const projection = await response.json(); + assert.ok(['recovery-required', 'degraded'].includes(projection.stateHealth), + 'Read-only GET must expose recovery-required/degraded, not silently commit pending state'); + assert.equal(projection.state.active, false, + 'Unrecovered canonical state must not be projected as a verified workflow'); + for (const [name, bytes] of Object.entries(before)) { + assert.deepEqual(fs.readFileSync(paths[name]), bytes, + 'Read-only GET must preserve exact ' + name + ' evidence'); + } + } finally { + await service.stop(); + } +}); + + +test('V13-API-PROJECTION-05 read-only preflight preserves terminated bootstrap staging hard link', async (t) => { + const rootDir = tempDir(t, 'dk-v013-api-bootstrap-readonly-'); + const state = createInitialState({ autonomy: 'guided-autopilot' }, rootDir); + saveStateRevision(state, rootDir); + const paths = getStateEnginePaths(rootDir); + const before = captureMaterialized(paths); + const schemaBytes = fs.readFileSync(paths.schema); + const service = new RuntimeApiService({ rootDir }); + const info = await service.start(); + const deadPid = 2000000000; + const stage = paths.schema + '.tmp-' + deadPid + '-' + crypto.randomUUID(); + try { + fs.linkSync(paths.schema, stage); + assert.equal(fs.statSync(paths.schema).nlink, 2, 'Fixture must reproduce the interrupted bootstrap hard link'); + const response = await fetch(info.url + '/v1/scale-context'); + assert.equal(response.status, 200); + const projection = await response.json(); + assert.ok(['recovery-required', 'degraded'].includes(projection.stateHealth), + 'A read-only projection must not complete bootstrap recovery'); + assert.equal(projection.state.active, false); + assert.equal(fs.existsSync(stage), true, 'GET must retain the exact staging-link crash witness'); + assert.equal(fs.statSync(paths.schema).nlink, 2, 'GET must not unlink a bootstrap staging hard link'); + assert.deepEqual(fs.readFileSync(paths.schema), schemaBytes); + for (const [field, bytes] of Object.entries(before)) { + assert.equal(fs.readFileSync(paths[field], 'utf8'), bytes, + 'GET must preserve ' + field + ' bytes'); + } + } finally { + await service.stop(); + } +}); + +test('V13-API-PROJECTION-06 surviving index must block a read-only canonical history regression', async (t) => { + const rootDir = tempDir(t, 'dk-v013-api-index-witness-'); + const state = createInitialState({ autonomy: 'guided-autopilot' }, rootDir); + saveStateRevision(state, rootDir); + const paths = getStateEnginePaths(rootDir); + const revisionOne = captureMaterialized(paths); + const next = { ...state, stateRevision: state.stateRevision + 1, + updatedAt: new Date(Date.parse(state.updatedAt) + 1000).toISOString() }; + saveStateRevision(next, rootDir); + const acknowledgedIndex = fs.readFileSync(paths.index); + const service = new RuntimeApiService({ rootDir }); + const info = await service.start(); + try { + fs.writeFileSync(paths.events, revisionOne.events); + fs.writeFileSync(paths.snapshot, revisionOne.snapshot); + const before = { + events: fs.readFileSync(paths.events), + snapshot: fs.readFileSync(paths.snapshot), + index: fs.readFileSync(paths.index), + }; + assert.deepEqual(before.index, acknowledgedIndex, 'Later index witness must survive'); + const response = await fetch(info.url + '/v1/scale-context'); + assert.equal(response.status, 200); + const projection = await response.json(); + assert.ok(['recovery-required', 'degraded'].includes(projection.stateHealth), + 'A later index witness must prevent projection of rolled-back history'); + assert.equal(projection.state.active, false); + assert.throws(() => loadStateSnapshot(rootDir, { rebuildIfNeeded: false }), + /canonical.*(truncated|witness|regression)|index.*witness/i); + for (const [field, bytes] of Object.entries(before)) { + assert.deepEqual(fs.readFileSync(paths[field]), bytes, + 'Read-only verification must preserve ' + field + ' evidence'); + } + } finally { + await service.stop(); + } +}); diff --git a/scripts/state-engine-v2-benchmark.mjs b/scripts/state-engine-v2-benchmark.mjs new file mode 100644 index 000000000..3161c8275 --- /dev/null +++ b/scripts/state-engine-v2-benchmark.mjs @@ -0,0 +1,60 @@ +// Deterministic, disposable T04 endurance fixture. Records observed performance; +// it does not turn a host-specific elapsed time into a false release acceptance. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { performance } from 'node:perf_hooks'; +import { + appendEntityState, + getStateEnginePaths, + loadCanonicalEvents, + loadEntityState, + verifyStateEngineIntegrity, +} from '../runtime/orchestration/state-engine-v2.mjs'; + +const countArg = process.argv.includes('--events') + ? Number(process.argv[process.argv.indexOf('--events') + 1]) + : 300; +assert.ok(Number.isInteger(countArg) && countArg >= 1 && countArg <= 5000, '--events must be between 1 and 5000'); +const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), 'dk-t04-endurance-')); +try { + const start = performance.now(); + let modeledOldRewriteBytes = 0; + for (let revision = 1; revision <= countArg; revision += 1) { + appendEntityState({ + rootDir, + entityType: 'endurance-fixture', + entityId: 'same-entity', + state: { stateRevision: revision, value: revision, mark: 'T04' }, + actorClass: 'endurance-test', + timestamp: new Date(Date.UTC(2026, 9, 9, 0, 0, 0) + revision * 1000).toISOString(), + }); + modeledOldRewriteBytes += fs.statSync(getStateEnginePaths(rootDir).events).size; + } + const writeDurationMs = performance.now() - start; + const readStart = performance.now(); + const events = loadCanonicalEvents(rootDir); + const state = loadEntityState('endurance-fixture', 'same-entity', rootDir); + const integrity = verifyStateEngineIntegrity(rootDir); + const readVerifyDurationMs = performance.now() - readStart; + assert.equal(events.length, countArg); + assert.equal(state.stateRevision, countArg); + assert.equal(integrity.valid, true); + const bytes = fs.statSync(getStateEnginePaths(rootDir).events).size; + console.log('T04_ENDURANCE_EVIDENCE ' + JSON.stringify({ + events: countArg, + platform: process.platform, + node: process.version, + writeDurationMs: Math.round(writeDurationMs), + readVerifyDurationMs: Math.round(readVerifyDurationMs), + canonicalLedgerBytes: bytes, + physicallyAppendedEventBytes: bytes, + historicalFullRewriteByteModel: modeledOldRewriteBytes, + rewriteBytesAvoidedPercentModeled: Number(((1 - bytes / modeledOldRewriteBytes) * 100).toFixed(2)), + pass: integrity.valid, + limitations: 'Historical full-rewrite traffic is computed from file sizes, not a measured legacy run. CPU parsing/replay is still full-history.', + })); +} finally { + fs.rmSync(rootDir, { recursive: true, force: true }); +} diff --git a/scripts/state-engine-v2-hardening.test.mjs b/scripts/state-engine-v2-hardening.test.mjs new file mode 100644 index 000000000..98561ddf7 --- /dev/null +++ b/scripts/state-engine-v2-hardening.test.mjs @@ -0,0 +1,1736 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import { createHash } from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { spawn } from 'node:child_process'; + +import { + appendEntityState, + appendMetadataEvent, + getStateEnginePaths, + loadCanonicalEvents, + loadStateSnapshot, + loadStateIndex, + rebuildStateSnapshot, + verifyStateEngineIntegrity, + recoverAbandonedStateLock, + ensureStateEngineLayout, +} from '../runtime/orchestration/state-engine-v2.mjs'; + +function tempProject(t) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'dk-t04-hardening-')); + t.after(() => fs.rmSync(dir, { recursive: true, force: true })); + return dir; +} + +test('T04-H01 physical event ledger retains its open file identity across commits', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const ledger = getStateEnginePaths(rootDir).events; + const descriptor = fs.openSync(ledger, 'r'); + try { + const before = fs.fstatSync(descriptor).size; + appendMetadataEvent({ rootDir, eventType: 'FOLLOWUP_EVENT', payload: { value: 2 } }); + assert.ok(fs.fstatSync(descriptor).size > before, 'append must grow the same canonical file, not rewrite its inode'); + assert.equal(loadCanonicalEvents(rootDir).length, 2); + } finally { + fs.closeSync(descriptor); + } +}); + +test('T04-H02 a live owner cannot lose an aged lock', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const lockFile = getStateEnginePaths(rootDir).lock; + fs.writeFileSync(lockFile, JSON.stringify({ + owner: 'live-long-running-worker', + pid: process.pid, + hostname: os.hostname(), + acquiredAt: '2026-10-08T00:00:00.000Z', + }), 'utf8'); + const old = new Date(Date.now() - 30_000); + fs.utimesSync(lockFile, old, old); + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'MUST_NOT_COMMIT', payload: { value: 2 } }), + /lock acquisition timed out/i, + ); + assert.equal(loadCanonicalEvents(rootDir).length, 1); + assert.equal(fs.existsSync(lockFile), true); +}); + +function runWorker(rootDir, workerId) { + return new Promise((resolve, reject) => { + const moduleUrl = new URL('../runtime/orchestration/state-engine-v2.mjs', import.meta.url).href; + const worker = [ + 'import { appendEntityState } from ' + JSON.stringify(moduleUrl) + ';', + 'const dir = process.argv[1], id = process.argv[2];', + 'for (let n = 1; n <= 12; n++) appendEntityState({ rootDir: dir, entityType: "worker-state", entityId: id, state: { stateRevision: n, value: n }, actorClass: "concurrency-test" });', + ].join('\n'); + const child = spawn(process.execPath, ['--input-type=module', '-e', worker, rootDir, workerId], { stdio: ['ignore', 'pipe', 'pipe'] }); + let stderr = ''; + child.stderr.on('data', data => { stderr += data; }); + child.on('error', reject); + child.on('close', code => code === 0 ? resolve() : reject(new Error('Worker ' + workerId + ' exited ' + code + ': ' + stderr.slice(-1600)))); + }); +} + +test('T04-H03 four processes append without losing events or corrupting sequence', async (t) => { + const rootDir = tempProject(t); + await Promise.all(Array.from({ length: 4 }, (_, i) => runWorker(rootDir, 'worker-' + i))); + const events = loadCanonicalEvents(rootDir); + assert.equal(events.length, 48); + assert.equal(events[0].sequence, 1); + assert.equal(events.at(-1).sequence, 48); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + + + +test('T04-H04 torn append with a valid pending journal deterministically repairs without losing committed history', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const before = fs.readFileSync(paths.events, 'utf8'); + appendMetadataEvent({ rootDir, eventType: 'FOLLOWUP_EVENT', payload: { value: 2 } }); + const expected = fs.readFileSync(paths.events, 'utf8'); + const suffix = expected.slice(before.length); + fs.writeFileSync(paths.events, before + suffix.slice(0, 23), 'utf8'); + fs.writeFileSync(paths.pending, JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: Buffer.byteLength(before), + previousHash: 'sha256:' + createHash('sha256').update(before).digest('hex'), + suffix, + }), 'utf8'); + + const recovered = loadCanonicalEvents(rootDir); + assert.equal(recovered.length, 2); + assert.equal(fs.readFileSync(paths.events, 'utf8'), expected); + assert.equal(fs.existsSync(paths.pending), false); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-H07 conflicting pending suffix must fail closed, not truncate canonical evidence', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const before = fs.readFileSync(paths.events, 'utf8'); + fs.writeFileSync(paths.events, before + 'tampered', 'utf8'); + fs.writeFileSync(paths.pending, JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: Buffer.byteLength(before), + previousHash: 'sha256:' + createHash('sha256').update(before).digest('hex'), + suffix: '{"valid":true}\n', + }), 'utf8'); + + assert.throws(() => loadCanonicalEvents(rootDir), /suffix conflicts/i); + assert.ok(fs.readFileSync(paths.events, 'utf8').endsWith('tampered')); + assert.equal(fs.existsSync(paths.pending), true); +}); + +test('T04-H08 stale lock from a provably dead local process can be recovered', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + fs.writeFileSync(paths.lock, JSON.stringify({ + owner: 'dead-worker', + pid: 2147483647, + hostname: os.hostname(), + acquiredAt: '2026-10-08T00:00:00.000Z', + }), 'utf8'); + const old = new Date(Date.now() - 30_000); + fs.utimesSync(paths.lock, old, old); + appendMetadataEvent({ rootDir, eventType: 'RECOVERED_LOCK', payload: { value: 2 } }); + assert.equal(loadCanonicalEvents(rootDir).length, 2); +}); + + +test('T04-H09 internal canonical events file may not be redirected through a symlink', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const outsideRoot = tempProject(t); + const outsideFile = path.join(outsideRoot, 'outside-ledger.jsonl'); + const originalContent = fs.readFileSync(paths.events, 'utf8'); + fs.writeFileSync(outsideFile, originalContent, 'utf8'); + fs.rmSync(paths.events); + try { + fs.symlinkSync(outsideFile, paths.events, 'file'); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { + t.skip('Host does not permit file symlink creation'); + return; + } + throw error; + } + assert.throws(() => loadCanonicalEvents(rootDir), /not a regular file/i); + assert.throws(() => appendMetadataEvent({ rootDir, eventType: 'DANGER', payload: {} }), /not a regular file/i); + assert.equal(fs.readFileSync(outsideFile, 'utf8'), originalContent); +}); + + +test('T04-H10 appending to a valid ledger without a terminal newline preserves record separation', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const original = fs.readFileSync(paths.events, 'utf8'); + assert.ok(original.endsWith('\n')); + fs.writeFileSync(paths.events, original.slice(0, -1), 'utf8'); + appendMetadataEvent({ rootDir, eventType: 'FOLLOWUP_EVENT', payload: { value: 2 } }); + const history = loadCanonicalEvents(rootDir); + assert.equal(history.length, 2); + assert.equal(history[1].previousEventHash, history[0].eventHash); + assert.ok(fs.readFileSync(paths.events, 'utf8').endsWith('\n')); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-H11 missing aged lock during stale-owner recheck retries acquisition safely', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + fs.writeFileSync(paths.lock, JSON.stringify({ + owner: 'provably-dead-owner', + pid: 2147483647, + hostname: os.hostname(), + acquiredAt: '2026-10-08T00:00:00.000Z', + })); + const aged = new Date(Date.now() - 30_000); + fs.utimesSync(paths.lock, aged, aged); + const originalStat = fs.statSync; + let lockStatCalls = 0; + fs.statSync = function injectConcurrentLockRemoval(target, ...args) { + if (target === paths.lock && ++lockStatCalls === 2) { + const err = new Error('simulated competing stale-lock reclamation'); + err.code = 'ENOENT'; + throw err; + } + return originalStat.call(this, target, ...args); + }; + try { + appendMetadataEvent({ rootDir, eventType: 'AFTER_STALE_LOCK_RACE', payload: { ok: true } }); + } finally { + fs.statSync = originalStat; + } + assert.ok(lockStatCalls >= 3); + assert.equal(loadCanonicalEvents(rootDir).length, 2); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + + +test('T04-H12 snapshot reader tolerates a legitimate writer committing between ledger and snapshot reads', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const originalRead = fs.readFileSync; + let insertedWriter = false; + fs.readFileSync = function simulateReadWriteInterleave(file, ...args) { + if (!insertedWriter && file === paths.snapshot) { + insertedWriter = true; + appendMetadataEvent({ rootDir, eventType: 'SIMULTANEOUS_EVENT', payload: { value: 2 } }); + } + return originalRead.call(this, file, ...args); + }; + let snapshot; + try { + snapshot = loadStateSnapshot(rootDir); + } finally { + fs.readFileSync = originalRead; + } + assert.equal(insertedWriter, true); + assert.equal(snapshot.lastEventSequence, 2); + assert.equal(loadCanonicalEvents(rootDir).length, 2); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + + +test('T04-H14 transient Windows EPERM on lock creation retries without bypassing ownership', (t) => { + if (process.platform !== 'win32') { + t.skip('Windows-specific filesystem contention semantics'); + return; + } + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const originalOpen = fs.openSync; + let attempts = 0; + fs.openSync = function injectTemporaryWindowsLockContention(file, flags, ...args) { + if (file === paths.lock && flags === 'wx') { + attempts += 1; + if (attempts === 1) { + const err = new Error('simulated Windows transient lock-file sharing violation'); + err.code = 'EPERM'; + throw err; + } + } + return originalOpen.call(this, file, flags, ...args); + }; + try { + appendMetadataEvent({ rootDir, eventType: 'LOCK_RETRY_SUCCEEDED', payload: { value: 2 } }); + } finally { + fs.openSync = originalOpen; + } + assert.ok(attempts >= 2); + assert.equal(loadCanonicalEvents(rootDir).length, 2); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + + +test('T04-H15 simultaneous stale-lock reclaimers cannot delete a new owner', async (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + fs.writeFileSync(paths.lock, JSON.stringify({ + owner: 'dead-before-contention', pid: 2147483647, hostname: os.hostname(), + acquiredAt: '2026-10-08T00:00:00.000Z', + })); + const aged = new Date(Date.now() - 30_000); + fs.utimesSync(paths.lock, aged, aged); + await Promise.all(Array.from({ length: 4 }, (_, i) => runWorker(rootDir, 'stale-worker-' + i))); + const records = loadCanonicalEvents(rootDir); + assert.equal(records.length, 49); + assert.equal(records.at(-1).sequence, 49); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-H16 canonical ledger hardlink cannot append to an external file', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const outsideRoot = tempProject(t); + const victim = path.join(outsideRoot, 'external-history.jsonl'); + const originalContent = fs.readFileSync(paths.events); + fs.writeFileSync(victim, originalContent); + fs.rmSync(paths.events); + try { + fs.linkSync(victim, paths.events); + } catch (e) { + if (['EPERM','EACCES','ENOTSUP','EXDEV'].includes(e.code)) { + t.skip('File hardlinks unavailable'); return; + } + throw e; + } + assert.throws(() => appendMetadataEvent({ rootDir, eventType: 'SHOULD_FAIL', payload: {} }), /hard.?link|multiple links/i); + assert.deepEqual(fs.readFileSync(victim), originalContent); +}); + +test('T04-H17 interrupted commit refreshes derived snapshot and index before clearing journal', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const before = fs.readFileSync(paths.events); + const oldSnapshot = fs.readFileSync(paths.snapshot); + const oldIndex = fs.readFileSync(paths.index); + appendMetadataEvent({ rootDir, eventType: 'FOLLOWUP_EVENT', payload: { value: 2 } }); + const after = fs.readFileSync(paths.events); + fs.writeFileSync(paths.snapshot, oldSnapshot); + fs.writeFileSync(paths.index, oldIndex); + fs.writeFileSync(paths.pending, JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: before.length, + previousHash: 'sha256:' + createHash('sha256').update(before).digest('hex'), + suffix: after.subarray(before.length).toString('utf8'), + })); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); + assert.equal(JSON.parse(fs.readFileSync(paths.snapshot, 'utf8')).lastEventSequence, 2); + assert.equal(JSON.parse(fs.readFileSync(paths.index, 'utf8')).sourceSequence, 2); + assert.equal(fs.existsSync(paths.pending), false); +}); + +test('T04-H18 stale reader cannot overwrite a later writer snapshot with old derived state', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const oldSnapshot = fs.readFileSync(paths.snapshot); + appendMetadataEvent({ rootDir, eventType: 'SECOND_EVENT', payload: { value: 2 } }); + fs.writeFileSync(paths.snapshot, oldSnapshot); + const originalRead = fs.readFileSync; + let insertedWriter = false; + fs.readFileSync = function staleReaderInterleave(file, ...args) { + if (!insertedWriter && file === paths.snapshot) { + insertedWriter = true; + const staleContent = originalRead.call(this, file, ...args); + appendMetadataEvent({ rootDir, eventType: 'THIRD_EVENT', payload: { value: 3 } }); + return staleContent; + } + return originalRead.call(this, file, ...args); + }; + try { + loadStateSnapshot(rootDir); + } finally { + fs.readFileSync = originalRead; + } + assert.equal(insertedWriter, true); + assert.equal(JSON.parse(fs.readFileSync(paths.snapshot, 'utf8')).lastEventSequence, 3); + assert.equal(JSON.parse(fs.readFileSync(paths.index, 'utf8')).sourceSequence, 3); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + + +test('T04-H22 event append refuses a symlink swapped into place immediately before open', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const outside = tempProject(t); + const victim = path.join(outside, 'must-not-change.txt'); + fs.writeFileSync(victim, 'EXTERNAL-SENTINEL', 'utf8'); + const originalOpen = fs.openSync; + let injected = false; + fs.openSync = function swapCanonicalPathAtAppendOpen(file, flags, ...args) { + const isAppend = flags === 'a' || + (typeof flags === 'number' && (flags & fs.constants.O_APPEND) !== 0); + const isTargetLedger = file === paths.events || + (typeof file === 'string' && file.includes('/proc/self/fd/') && file.endsWith('/events.jsonl')); + if (!injected && isTargetLedger && isAppend) { + const displaced = path.join(paths.stateRoot, 'original-ledger-for-test.jsonl'); + fs.renameSync(paths.events, displaced); + try { + fs.symlinkSync(victim, paths.events, 'file'); + } catch (err) { + fs.renameSync(displaced, paths.events); + if (['EPERM','EACCES','ENOTSUP'].includes(err.code)) { + t.skip('Host denies file symlink fixture'); + skipped = true; + return originalOpen.call(this, file, flags, ...args); + } + throw err; + } + injected = true; + } + return originalOpen.call(this, file, flags, ...args); + }; + let skipped = false; + try { + try { + appendMetadataEvent({ rootDir, eventType: 'MUST_NOT_ESCAPE', payload: {} }); + if (!skipped) { + assert.fail('Missing expected exception'); + } + } catch (err) { + if (skipped) return; + assert.match(String(err), /symlink|symbolic|inode|link|ELOOP|outside|State Engine/i); + } + } finally { + fs.openSync = originalOpen; + } + if (!injected || skipped) return; + assert.equal(fs.readFileSync(victim, 'utf8'), 'EXTERNAL-SENTINEL'); +}); + + +test('T04-H26 abandoned recovery guard is recovered only by explicit offline confirmation', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const guard = paths.lock + '.reclaim'; + fs.mkdirSync(guard, { mode: 0o700 }); + fs.writeFileSync(paths.lock, JSON.stringify({ + owner: 'dead-lock-owner', pid: 2147483647, + hostname: os.hostname(), acquiredAt: '2026-10-08T00:00:00.000Z', + })); + const ago = new Date(Date.now() - 120_000); + fs.utimesSync(paths.lock, ago, ago); + fs.utimesSync(guard, ago, ago); + assert.throws(() => recoverAbandonedStateLock({ rootDir }), /offline confirmation/i); + assert.equal(fs.existsSync(guard), true); + const recovered = recoverAbandonedStateLock({ rootDir, confirmOffline: true }); + assert.equal(recovered.recovered, true); + assert.equal(fs.existsSync(guard), false); + appendMetadataEvent({ rootDir, eventType: 'AFTER_ORPHAN_RECOVERY', payload: { value: 2 } }); + assert.equal(loadCanonicalEvents(rootDir).length, 2); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-H27 offline orphan guard recovery refuses a live owner', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const guard = paths.lock + '.reclaim'; + fs.mkdirSync(guard, { mode: 0o700 }); + fs.writeFileSync(path.join(guard, 'owner.json'), JSON.stringify({ + owner: 'still-running', pid: process.pid, hostname: os.hostname(), + })); + const ago = new Date(Date.now() - 120_000); + fs.utimesSync(guard, ago, ago); + assert.throws(() => recoverAbandonedStateLock({ rootDir, confirmOffline: true }), /live State Engine recovery guard/i); + assert.equal(fs.existsSync(guard), true); +}); + + +test('T04-H29 interrupted ledger recovery must never truncate an external hardlink swapped during read', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const prefix = fs.readFileSync(paths.events); + appendMetadataEvent({ rootDir, eventType: 'FOLLOWUP_EVENT', payload: { value: 2 } }); + const complete = fs.readFileSync(paths.events); + const suffix = complete.subarray(prefix.length); + fs.writeFileSync(paths.events, Buffer.concat([prefix, suffix.subarray(0, 40)])); + fs.writeFileSync(paths.pending, JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: prefix.length, + previousHash: 'sha256:' + createHash('sha256').update(prefix).digest('hex'), + suffix: suffix.toString('utf8'), + })); + const outside = tempProject(t); + const victim = path.join(outside, 'external-ledger.jsonl'); + fs.writeFileSync(victim, Buffer.concat([prefix, Buffer.alloc(500, 0x58)])); + const expectedExternal = fs.readFileSync(victim); + const originalRead = fs.readFileSync; + let swapped = false; + fs.readFileSync = function swapAfterLedgerRead(file, ...args) { + const bytes = originalRead.call(this, file, ...args); + if (!swapped && file === paths.events) { + swapped = true; + fs.renameSync(paths.events, path.join(paths.stateRoot, 'displaced-ledger')); + fs.linkSync(victim, paths.events); + } + return bytes; + }; + try { + // The corrected implementation may reject any inode substitution or + // complete on its original descriptor. Neither may corrupt the victim. + try { loadCanonicalEvents(rootDir); } catch (error) { + assert.match(String(error), /canonical|inode|link|history|State Engine/i); + } + } finally { + fs.readFileSync = originalRead; + } + assert.deepEqual(fs.readFileSync(victim), expectedExternal); +}); + + +test('T04-H36 pending journal cannot overwrite external file after state-directory swap', (t) => { + if (process.platform === 'win32') { + t.skip('Pinned Windows directory handles prevent this POSIX directory-swap fixture'); + return; + } + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'FIRST', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const outside = tempProject(t); + const protectedFile = path.join(outside, 'pending-commit.json'); + fs.writeFileSync(protectedFile, 'EXTERNAL-DATA-MUST-STAY', 'utf8'); + const moved = paths.stateRoot + '-held-for-test'; + const originalOpen = fs.openSync; + let swapped = false; + fs.openSync = function swapRootAtJournalTempOpen(target, flags, ...rest) { + if (!swapped && typeof target === 'string' && + target.includes('pending-commit.json.tmp-') && flags === 'wx') { + try { + fs.renameSync(paths.stateRoot, moved); + } catch (err) { + if (process.platform === 'win32' && err.code === 'EPERM') { + t.skip('Windows locks directory handles against renaming while pinned open'); + return originalOpen.call(this, target, flags, ...rest); + } + throw err; + } + try { + fs.symlinkSync(outside, paths.stateRoot, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + fs.renameSync(moved, paths.stateRoot); + if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { + t.skip('Host does not permit test directory symlinks/junctions'); + return originalOpen.call(this, target, flags, ...rest); + } + throw error; + } + swapped = true; + } + return originalOpen.call(this, target, flags, ...rest); + }; + try { + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'SECOND_MUST_NOT_PUBLISH', payload: {} }), + /directory|symbolic|changed|outside|project root|symlink|mismatch/i, + ); + } finally { + fs.openSync = originalOpen; + if (swapped) { + fs.unlinkSync(paths.stateRoot); + fs.renameSync(moved, paths.stateRoot); + } + } + assert.equal(fs.readFileSync(protectedFile, 'utf8'), 'EXTERNAL-DATA-MUST-STAY'); + assert.equal(loadCanonicalEvents(rootDir).length, 1); +}); + +test('T04-H37 pending journal unlink fails closed when state directory is replaced during commit', (t) => { + if (process.platform === 'win32') { + t.skip('Pinned Windows directory handles prevent this POSIX directory-swap fixture'); + return; + } + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'FIRST', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const outside = tempProject(t); + const protectedFile = path.join(outside, 'pending-commit.json'); + fs.writeFileSync(protectedFile, 'EXTERNAL-SENTINEL-DO-NOT-DELETE', 'utf8'); + const moved = paths.stateRoot + '-held-for-unlink-test'; + const originalOpen = fs.openSync; + let swapped = false; + + // Intercept before unlink or atomic write + let journalCreated = false; + fs.openSync = function swapRootAtJournalUnlinkOpen(target, flags, ...rest) { + if (typeof target === 'string' && target.includes('pending-commit.json.tmp-')) { + journalCreated = true; + } + if (journalCreated && !swapped && typeof target === 'string' && (target.includes('snapshot.json') || target === paths.snapshot) && typeof flags === 'string' && flags.includes('w')) { + try { + fs.renameSync(paths.stateRoot, moved); + try { + fs.symlinkSync(outside, paths.stateRoot, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + fs.renameSync(moved, paths.stateRoot); + if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { + t.skip('Host does not permit test directory symlinks/junctions'); + return originalOpen.call(this, target, flags, ...rest); + } + throw error; + } + swapped = true; + } catch (err) { + if (process.platform === 'win32' && err.code === 'EPERM') { + // Open directory descriptors prevent renaming the directory on Windows + t.skip('Windows locks directory handles against renaming while pinned open'); + return originalOpen.call(this, target, flags, ...rest); + } + throw err; + } + } + return originalOpen.call(this, target, flags, ...rest); + }; + + try { + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'SECOND_MUST_FAIL_CLEANUP', payload: {} }), + /directory|symbolic|changed|replaced|journal cleanup|mismatch/i, + ); + } finally { + fs.openSync = originalOpen; + if (swapped) { + fs.unlinkSync(paths.stateRoot); + fs.renameSync(moved, paths.stateRoot); + } + } + + assert.equal(fs.readFileSync(protectedFile, 'utf8'), 'EXTERNAL-SENTINEL-DO-NOT-DELETE', + 'External victim pending-commit.json must not be unlinked'); +}); + +test('T04-H38 pending journal unlink fails closed when state directory is swapped with an ordinary directory across transaction', (t) => { + if (process.platform === 'win32') { + t.skip('Pinned Windows directory handles prevent this POSIX directory-swap fixture'); + return; + } + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'FIRST', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const moved = paths.stateRoot + '-held-for-ordinary-dir-swap'; + const originalOpen = fs.openSync; + let swapped = false; + let journalCreated = false; + + fs.openSync = function swapRootWithOrdinaryDirectory(target, flags, ...rest) { + if (typeof target === 'string' && target.includes('pending-commit.json.tmp-')) { + journalCreated = true; + } + // Trigger swap after journal file is opened/written but before unlink + const isTargetLedger = target === paths.events || + (typeof target === 'string' && target.includes('/proc/self/fd/') && target.endsWith('/events.jsonl')); + if (journalCreated && !swapped && isTargetLedger && typeof flags === 'number') { + try { + fs.renameSync(paths.stateRoot, moved); + // Create a brand new ordinary directory at paths.stateRoot with a decoy pending-commit.json + fs.mkdirSync(paths.stateRoot, { recursive: true }); + fs.writeFileSync(paths.pending, 'DECOY-JOURNAL', 'utf8'); + swapped = true; + } catch (err) { + if (process.platform === 'win32' && err.code === 'EPERM') { + t.skip('Windows locks directory handles against renaming while pinned open'); + return originalOpen.call(this, target, flags, ...rest); + } + throw err; + } + } + return originalOpen.call(this, target, flags, ...rest); + }; + + try { + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'SECOND_MUST_FAIL_CLEANUP', payload: {} }), + /directory|changed|replaced/i, + ); + } finally { + fs.openSync = originalOpen; + if (swapped) { + fs.rmSync(paths.stateRoot, { recursive: true, force: true }); + fs.renameSync(moved, paths.stateRoot); + } + } +}); + +test('T04-H40 durable transaction writes fail closed when state directory is replaced before append or derived writes', (t) => { + if (process.platform === 'win32') { + t.skip('Pinned Windows directory handles prevent this POSIX directory-swap fixture'); + return; + } + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'FIRST', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const moved = paths.stateRoot + '-held-for-swap'; + const originalOpen = fs.openSync; + let swapped = false; + let journalCreated = false; + + fs.openSync = function swapRootBeforeAppend(target, flags, ...rest) { + if (typeof target === 'string' && target.includes('pending-commit.json.tmp-')) { + journalCreated = true; + } + // Swap state directory right before appendDurably opens paths.events + if (journalCreated && !swapped && (target === paths.events || (typeof target === 'string' && target.endsWith('/events.jsonl')))) { + try { + fs.renameSync(paths.stateRoot, moved); + fs.mkdirSync(paths.stateRoot, { recursive: true }); + fs.writeFileSync(paths.events, fs.readFileSync(path.join(moved, 'events.jsonl'))); + swapped = true; + } catch (err) { + if (process.platform === 'win32' && err.code === 'EPERM') { + t.skip('Windows locks directory handles against renaming while pinned open'); + return originalOpen.call(this, target, flags, ...rest); + } + throw err; + } + } + return originalOpen.call(this, target, flags, ...rest); + }; + + try { + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'SECOND_MUST_FAIL_APPEND', payload: {} }), + /directory|changed|replaced/i, + ); + } finally { + fs.openSync = originalOpen; + if (swapped) { + fs.rmSync(paths.stateRoot, { recursive: true, force: true }); + fs.renameSync(moved, paths.stateRoot); + } + } +}); + +test('T04-H41 recovery resolves pending journal, events, and snapshot relative to pinned directory descriptor', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const before = fs.readFileSync(paths.events, 'utf8'); + appendMetadataEvent({ rootDir, eventType: 'FOLLOWUP_EVENT', payload: { value: 2 } }); + const expected = fs.readFileSync(paths.events, 'utf8'); + const suffix = expected.slice(before.length); + // Simulate torn append with pending journal + fs.writeFileSync(paths.events, before + suffix.slice(0, 10), 'utf8'); + fs.writeFileSync(paths.pending, JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: Buffer.byteLength(before), + previousHash: 'sha256:' + createHash('sha256').update(before).digest('hex'), + suffix, + }), 'utf8'); + + const openedTargets = []; + const originalOpen = fs.openSync; + fs.openSync = function trackOpen(target, flags, ...rest) { + if (typeof target === 'string') { + openedTargets.push(target); + } + return originalOpen.call(this, target, flags, ...rest); + }; + + try { + const recovered = loadStateSnapshot(rootDir, { rebuildIfNeeded: true }); + assert.ok(recovered); + assert.equal(recovered.lastEventSequence, 2); + if (process.platform === 'linux') { + const procFdEvents = openedTargets.find(p => p.includes('/proc/self/fd/') && p.endsWith('events.jsonl')); + assert.ok(procFdEvents, 'Linux recovery must open events ledger through /proc/self/fd/ descriptor path'); + } + } finally { + fs.openSync = originalOpen; + } +}); + +test('T04-H42 appendDurably fails closed if canonical ledger entry is displaced during write', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'INITIAL_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const displaced = path.join(paths.stateRoot, 'displaced-events.jsonl'); + + const originalWriteSync = fs.writeSync; + let swapped = false; + fs.writeSync = function hookedWrite(fd, buffer, offset, length, position) { + if (!swapped) { + try { + fs.renameSync(paths.events, displaced); + fs.writeFileSync(paths.events, 'MUTATED_NEW_FILE\n', 'utf8'); + swapped = true; + } catch (err) { + if (process.platform === 'win32' && err.code === 'EPERM') { + t.skip('Windows locks open files against renaming'); + return originalWriteSync.call(this, fd, buffer, offset, length, position); + } + throw err; + } + } + return originalWriteSync.call(this, fd, buffer, offset, length, position); + }; + + try { + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'MUST_FAIL_EVENT', payload: { value: 2 } }), + /displaced|changed|mismatch/i, + ); + } finally { + fs.writeSync = originalWriteSync; + if (swapped) { + fs.rmSync(paths.events, { force: true }); + fs.renameSync(displaced, paths.events); + } + } +}); + + + + +function swapLedgerBeforeJournalUnlink(t, paths, replacementContent) { + const originalUnlink = fs.unlinkSync; + const displaced = path.join(paths.stateRoot, 'displaced-events.jsonl'); + const state = { swapped: false, skipped: false, displaced, restore() { + fs.unlinkSync = originalUnlink; + } }; + fs.unlinkSync = function swapBetweenFinalLstatAndUnlink(target, ...rest) { + if (!state.swapped && typeof target === 'string' && target.endsWith('pending-commit.json')) { + try { + fs.renameSync(paths.events, displaced); + fs.writeFileSync(paths.events, replacementContent, 'utf8'); + state.swapped = true; + } catch (err) { + if (process.platform === 'win32' && ['EPERM', 'EBUSY', 'EACCES'].includes(err.code)) { + state.skipped = true; + t.skip('Windows denies renaming the open ledger; Ubuntu CI provides the inode-swap evidence'); + } else { + throw err; + } + } + } + return originalUnlink.call(this, target, ...rest); + }; + return state; +} + +test('T04-H43 ledger swap between final lstat and journal unlink is detected fail-closed on next load (append)', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const preLedger = fs.readFileSync(paths.events, 'utf8'); + const state = swapLedgerBeforeJournalUnlink(t, paths, preLedger); + try { + appendMetadataEvent({ rootDir, eventType: 'AMBIGUOUS_EVENT', payload: { value: 2 } }); + } finally { + state.restore(); + } + if (state.skipped) return; + assert.equal(state.swapped, true); + // Post-condition witnesses: canonical authority is the replaced ledger left directly in place by the hook, + // the pending journal was removed, and derived state is ahead of the replaced ledger. + assert.equal(fs.existsSync(paths.pending), false); + assert.equal(fs.existsSync(state.displaced), true); + assert.equal(fs.readFileSync(state.displaced, 'utf8').split('\n').filter(Boolean).length, 2, + 'displaced ledger holds both committed events'); + assert.equal(fs.readFileSync(paths.events, 'utf8'), preLedger, + 'events.jsonl contains the authentic swapped preLedger without synthetic post-operation rewrite'); + assert.equal(loadCanonicalEvents(rootDir).length, 1); + assert.throws(() => loadStateSnapshot(rootDir, { rebuildIfNeeded: false }), /truncated|differs|materialized/i); + assert.throws(() => verifyStateEngineIntegrity(rootDir), /truncated|differs|materialized|equivalent/i); +}); + +test('T04-H44 ledger swap between final lstat and journal unlink is detected fail-closed on next load (recovery)', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const before = fs.readFileSync(paths.events, 'utf8'); + appendMetadataEvent({ rootDir, eventType: 'FOLLOWUP_EVENT', payload: { value: 2 } }); + const suffix = fs.readFileSync(paths.events, 'utf8').slice(before.length); + fs.writeFileSync(paths.events, before + suffix.slice(0, 10), 'utf8'); + fs.writeFileSync(paths.pending, JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: Buffer.byteLength(before), + previousHash: 'sha256:' + createHash('sha256').update(before).digest('hex'), + suffix, + }), 'utf8'); + const state = swapLedgerBeforeJournalUnlink(t, paths, before); + try { + assert.throws(() => loadStateSnapshot(rootDir, { rebuildIfNeeded: true }), /truncated|differs|materialized/i); + } finally { + state.restore(); + } + if (state.skipped) return; + assert.equal(state.swapped, true); + assert.equal(fs.existsSync(paths.pending), false); + assert.equal(fs.existsSync(state.displaced), true); + assert.equal(fs.readFileSync(paths.events, 'utf8'), before, + 'events.jsonl contains the authentic swapped before ledger without synthetic rewrite'); + assert.throws(() => loadStateSnapshot(rootDir, { rebuildIfNeeded: false }), /truncated|differs|materialized/i); + assert.throws(() => verifyStateEngineIntegrity(rootDir), /truncated|differs|materialized|equivalent/i); +}); + +test('T04-H45 missing events.jsonl with pending journal present fails closed across all accessors', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + + // Setup pending commit marker + fs.writeFileSync(paths.pending, JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: 0, + previousHash: 'sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855', + suffix: '{"schemaVersion":"1.0.0"}\n', + }), 'utf8'); + + // Remove the canonical events ledger + fs.unlinkSync(paths.events); + + // Verify loadCanonicalEvents fails closed + assert.throws( + () => loadCanonicalEvents(rootDir), + /events\.jsonl is missing while pending-commit\.json is present/i, + ); + + // Verify loadStateSnapshot fails closed + assert.throws( + () => loadStateSnapshot(rootDir, { rebuildIfNeeded: false }), + /events\.jsonl is missing while pending-commit\.json is present/i, + ); + + // Verify ensureStateEngineLayout fails closed rather than wiping/creating an empty ledger + assert.throws( + () => ensureStateEngineLayout(rootDir), + /events\.jsonl is missing while pending-commit\.json is present/i, + ); + + // Verify verifyStateEngineIntegrity fails closed + assert.throws( + () => verifyStateEngineIntegrity(rootDir), + /events\.jsonl is missing while pending-commit\.json is present/i, + ); +}); + +test('T04-H46 recovery fsyncs a fully present but unflushed suffix before acknowledging journal', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H46_INITIAL', payload: { seq: 1 } }); + const paths = getStateEnginePaths(rootDir); + const before = fs.readFileSync(paths.events, 'utf8'); + appendMetadataEvent({ rootDir, eventType: 'H46_FOLLOWUP', payload: { seq: 2 } }); + const full = fs.readFileSync(paths.events, 'utf8'); + const suffix = full.slice(before.length); + // Writer died after writing the complete suffix, before fsync and journal removal. + fs.writeFileSync(paths.pending, JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: Buffer.byteLength(before), + previousHash: 'sha256:' + createHash('sha256').update(before).digest('hex'), + suffix, + }), 'utf8'); + const ledger = fs.statSync(paths.events); + const originalFsync = fs.fsyncSync; + const originalUnlink = fs.unlinkSync; + let ledgerFlushed = false; + let observedJournalRemoval = false; + fs.fsyncSync = function trackLedgerFsync(fd) { + const stat = fs.fstatSync(fd); + if (stat.isFile() && stat.dev === ledger.dev && stat.ino === ledger.ino) ledgerFlushed = true; + return originalFsync.call(this, fd); + }; + fs.unlinkSync = function guardJournalUnlink(target, ...args) { + if (typeof target === 'string' && target.endsWith('pending-commit.json')) { + observedJournalRemoval = true; + assert.ok(ledgerFlushed, + 'Recovery must fsync fully present canonical suffix before removing pending journal'); + } + return originalUnlink.call(this, target, ...args); + }; + try { + assert.equal(loadStateSnapshot(rootDir, { rebuildIfNeeded: true }).lastEventSequence, 2); + assert.ok(observedJournalRemoval); + assert.equal(fs.existsSync(paths.pending), false); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); + } finally { + fs.fsyncSync = originalFsync; + fs.unlinkSync = originalUnlink; + } +}); + + +test('T04-H49 Windows refuses canonical ledger mutation when pending-journal directory flush fails', (t) => { + if (process.platform !== 'win32') { + t.skip('Windows write-capable directory flush barrier'); + return; + } + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H49_BASELINE', payload: { step: 1 } }); + const paths = getStateEnginePaths(rootDir); + const before = fs.readFileSync(paths.events); + const dir = fs.statSync(paths.stateRoot); + const originalFsync = fs.fsyncSync; + let injected = false; + fs.fsyncSync = function failStateDirectory(fd) { + const witness = fs.fstatSync(fd); + if (witness.isDirectory() && witness.dev === dir.dev && witness.ino === dir.ino) { + injected = true; + const error = new Error('injected Windows namespace durability failure'); + error.code = 'EIO'; + throw error; + } + return originalFsync.call(this, fd); + }; + try { + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'H49_FOLLOWUP', payload: { step: 2 } }), + /directory durability barrier failed/i, + ); + } finally { + fs.fsyncSync = originalFsync; + } + assert.equal(injected, true); + assert.deepEqual(fs.readFileSync(paths.events), before, + 'The ledger must not be changed if publishing the pending journal is not durable'); + assert.equal(fs.existsSync(paths.pending), true, + 'Keep recovery evidence when pending journal namespace cannot be flushed'); +}); + +test('T04-H50 Windows flushes pending-journal directory before any physical canonical append', (t) => { + if (process.platform !== 'win32') { + t.skip('Windows pending-journal namespace ordering'); + return; + } + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H50_BASELINE', payload: { step: 1 } }); + const paths = getStateEnginePaths(rootDir); + const dir = fs.statSync(paths.stateRoot); + const ledger = fs.statSync(paths.events); + const originalFsync = fs.fsyncSync; + const originalWrite = fs.writeSync; + let namespaceFlushed = false; + let sawLedgerAppend = false; + fs.fsyncSync = function watchStateDirectoryFlush(fd) { + const stat = fs.fstatSync(fd); + const returned = originalFsync.call(this, fd); + if (stat.isDirectory() && stat.dev === dir.dev && stat.ino === dir.ino) { + namespaceFlushed = true; + } + return returned; + }; + fs.writeSync = function guardLedgerAppend(fd, ...args) { + const stat = fs.fstatSync(fd); + if (stat.isFile() && stat.dev === ledger.dev && stat.ino === ledger.ino) { + sawLedgerAppend = true; + assert.equal(namespaceFlushed, true, + 'The pending journal must be flushed in its parent before appending ledger bytes'); + } + return originalWrite.call(this, fd, ...args); + }; + try { + appendMetadataEvent({ rootDir, eventType: 'H50_FOLLOWUP', payload: { step: 2 } }); + } finally { + fs.fsyncSync = originalFsync; + fs.writeSync = originalWrite; + } + assert.equal(sawLedgerAppend, true); + assert.equal(fs.existsSync(paths.pending), false); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-P1 initial layout cannot overwrite an acknowledged concurrent first event', (t) => { + const rootDir = tempProject(t); + const paths = getStateEnginePaths(rootDir); + const originalRename = fs.renameSync; + const originalOpen = fs.openSync; + let injected = false; + const injectWriter = () => { + injected = true; + appendMetadataEvent({ + rootDir, eventType: 'RACE_COMMIT', actorClass: 'concurrency-regression', + payload: { witness: 'acknowledged-before-initial-layout' }, + }); + }; + fs.renameSync = function raceOnFirstLedgerPublication(from, to, ...rest) { + if (!injected && path.basename(String(to)) === 'events.jsonl' && String(from).includes('events.jsonl.tmp-')) { + injectWriter(); + } + return originalRename.call(this, from, to, ...rest); + }; + fs.openSync = function raceOnExclusiveLedgerCreation(file, flags, ...rest) { + if (!injected && path.basename(String(file)) === 'events.jsonl' && flags === 'wx') injectWriter(); + return originalOpen.call(this, file, flags, ...rest); + }; + try { + ensureStateEngineLayout(rootDir); + } finally { + fs.renameSync = originalRename; + fs.openSync = originalOpen; + } + assert.equal(injected, true, 'fixture must interleave first layout with a successful writer'); + const ledger = fs.readFileSync(paths.events, 'utf8'); + assert.match(ledger, /RACE_COMMIT/, 'initialization must not replace an acknowledged canonical event'); + assert.equal(loadCanonicalEvents(rootDir).length, 1); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-H52 corrupted pending suffix is rejected before mutating canonical bytes or journal', (t) => { + for (const partialLength of [0, 20]) { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H52_BASELINE', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const baseline = fs.readFileSync(paths.events, 'utf8'); + appendMetadataEvent({ rootDir, eventType: 'H52_FOLLOWUP', payload: { value: 2 } }); + const validSuffix = fs.readFileSync(paths.events, 'utf8').slice(baseline.length); + const invalidEvent = JSON.parse(validSuffix.trim()); + invalidEvent.eventHash = 'sha256:' + '0'.repeat(64); + const invalidSuffix = JSON.stringify(invalidEvent) + '\n'; + const originalLedger = baseline + invalidSuffix.slice(0, partialLength); + const journal = JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: Buffer.byteLength(baseline), + previousHash: 'sha256:' + createHash('sha256').update(baseline).digest('hex'), + suffix: invalidSuffix, + }) + '\n'; + fs.writeFileSync(paths.events, originalLedger, 'utf8'); + fs.writeFileSync(paths.pending, journal, 'utf8'); + assert.throws(() => loadCanonicalEvents(rootDir), /event hash mismatch|invalid|integrity/i); + assert.equal(fs.readFileSync(paths.events, 'utf8'), originalLedger, + 'Invalid pending event must not overwrite original crash evidence (partialLength=' + partialLength + ')'); + assert.equal(fs.readFileSync(paths.pending, 'utf8'), journal, + 'Rejected pending journal must remain available for operator diagnosis'); + } +}); + + +test('T04-H56 pending recovery preserves canonical evidence when snapshot witness read is denied', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const baseline = fs.readFileSync(paths.events, 'utf8'); + appendMetadataEvent({ rootDir, eventType: 'FOLLOWUP_EVENT', payload: { value: 2 } }); + const suffix = fs.readFileSync(paths.events, 'utf8').slice(baseline.length); + const torn = baseline + suffix.slice(0, Math.max(1, Math.floor(suffix.length / 3))); + fs.writeFileSync(paths.events, torn, 'utf8'); + fs.writeFileSync(paths.pending, JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: Buffer.byteLength(baseline), + previousHash: 'sha256:' + createHash('sha256').update(baseline).digest('hex'), + suffix, + }), 'utf8'); + + const originalRead = fs.readFileSync; + let denied = 0; + fs.readFileSync = function denySnapshotWitnessRead(target, ...args) { + if (path.basename(String(target)) === path.basename(paths.snapshot)) { + denied += 1; + const error = new Error('Synthetic snapshot witness permission denial'); + error.code = 'EACCES'; + throw error; + } + return originalRead.call(this, target, ...args); + }; + try { + assert.throws( + () => loadStateSnapshot(rootDir, { rebuildIfNeeded: true }), + /snapshot witness|EACCES|permission denial/i, + 'A denied snapshot witness read must fail closed before canonical repair', + ); + } finally { + fs.readFileSync = originalRead; + } + assert.ok(denied > 0, 'Fault injection must intercept the witness read'); + assert.equal(fs.readFileSync(paths.events, 'utf8'), torn, + 'Failed witness read must not truncate or append canonical event bytes'); + assert.equal(fs.existsSync(paths.pending), true, + 'Failed witness read must retain the pending journal for operator recovery'); +}); + +/** + * T04-H57: a canonical ledger may be swapped for an external hard link + * after the recovery preflight but before the repair opens it for mutation. + * Both newline completion and torn-tail truncation must fail closed. + */ +for (const repairMode of ['missing-terminal-newline', 'torn-trailing-json']) { + test('T04-H57 ledger tail repair rejects a swapped hard link (' + repairMode + ')', (t) => { + const rootDir = tempProject(t); + const outsideRoot = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const baseline = fs.readFileSync(paths.events, 'utf8'); + const damaged = repairMode === 'missing-terminal-newline' + ? baseline.slice(0, -1) + : baseline + '{"incomplete":'; + fs.writeFileSync(paths.events, damaged, 'utf8'); + const outsideFile = path.join(outsideRoot, 'unrelated-evidence.jsonl'); + fs.writeFileSync(outsideFile, damaged, 'utf8'); + + const originalOpen = fs.openSync; + let swaps = 0; + fs.openSync = function injectHardLinkAtRepairOpen(target, flags, ...args) { + const writeRepair = flags === 'r+' || + (typeof flags === 'number' && + (flags & fs.constants.O_RDWR) === fs.constants.O_RDWR); + if (target === paths.events && writeRepair) { + swaps += 1; + fs.unlinkSync(paths.events); + fs.linkSync(outsideFile, paths.events); + } + return originalOpen.call(this, target, flags, ...args); + }; + try { + assert.throws( + () => loadCanonicalEvents(rootDir), + /canonical ledger|hard.?link|integrity|identity|unsafe/i, + 'A post-preflight hard-link substitution must be rejected before repair writes', + ); + } finally { + fs.openSync = originalOpen; + } + assert.equal(swaps, 1, 'Fault injection must intercept the repair descriptor open'); + assert.equal(fs.readFileSync(outsideFile, 'utf8'), damaged, + 'Recovery must never append to or truncate unrelated external evidence'); + assert.equal(fs.readFileSync(paths.events, 'utf8'), damaged, + 'Rejected repair must not alter the substituted canonical path'); + }); +} + +test('T04-H58 unreadable snapshot witness cannot authorize no-journal torn-tail truncation', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + appendMetadataEvent({ rootDir, eventType: 'ACKNOWLEDGED_EVENT', payload: { value: 2 } }); + const paths = getStateEnginePaths(rootDir); + const ledger = fs.readFileSync(paths.events); + const finalLineStart = ledger.lastIndexOf(0x0a, ledger.length - 2) + 1; + const torn = Buffer.concat([ledger.subarray(0, finalLineStart), + ledger.subarray(finalLineStart, finalLineStart + 21)]); + fs.writeFileSync(paths.events, torn); + assert.equal(fs.existsSync(paths.pending), false); + const originalRead = fs.readFileSync; + let denied = 0; + fs.readFileSync = function denyTailSnapshotWitness(target, ...args) { + if (String(target) === paths.snapshot) { + denied += 1; + const error = new Error('Synthetic snapshot witness access denied'); + error.code = 'EACCES'; + throw error; + } + return originalRead.call(this, target, ...args); + }; + try { + assert.throws(() => loadCanonicalEvents(rootDir), + /snapshot witness|EACCES|access denied/i, + 'An unreadable acknowledged witness must block destructive tail repair'); + } finally { + fs.readFileSync = originalRead; + } + assert.ok(denied > 0, 'Fault injection must intercept the snapshot witness'); + assert.deepEqual(fs.readFileSync(paths.events), torn, + 'Rejected tail repair must retain all original torn canonical bytes'); +}); + +test('T04-H59 incomplete multibyte UTF-8 tail is truncated using raw canonical bytes', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'BASELINE_EVENT', payload: { value: 1 } }); + const paths = getStateEnginePaths(rootDir); + const baseline = fs.readFileSync(paths.events); + const torn = Buffer.concat([baseline, Buffer.from('{"payload":"'), + Buffer.from([0xe2, 0x82])]); + fs.writeFileSync(paths.events, torn); + assert.equal(fs.existsSync(paths.pending), false); + const recovered = loadCanonicalEvents(rootDir); + assert.equal(recovered.length, 1); + assert.deepEqual(fs.readFileSync(paths.events), baseline, + 'A torn UTF-8 suffix must be removed without rewriting valid history'); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-H60 valid unterminated ledger event survives ENOSPC during newline repair', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H60_VALID_EVENT', payload: { value: 60 } }); + const paths = getStateEnginePaths(rootDir); + const validWithoutNewline = fs.readFileSync(paths.events).subarray(0, -1); + fs.writeFileSync(paths.events, validWithoutNewline); + // No acknowledged derived witness: a failed newline write must not be + // misclassified as malformed JSON and authorize destructive truncation. + fs.unlinkSync(paths.snapshot); + const canonical = fs.statSync(paths.events); + const originalWrite = fs.writeSync; + let injected = 0; + fs.writeSync = function denyLedgerNewline(fd, buffer, ...args) { + const stat = fs.fstatSync(fd); + if (stat.isFile() && stat.dev === canonical.dev && stat.ino === canonical.ino && + Buffer.isBuffer(buffer) && buffer.length === 1 && buffer[0] === 10) { + injected += 1; + const error = new Error('Synthetic ENOSPC during valid event newline completion'); + error.code = 'ENOSPC'; + throw error; + } + return originalWrite.call(this, fd, buffer, ...args); + }; + try { + assert.throws(() => loadCanonicalEvents(rootDir), /ENOSPC|newline completion/i, + 'A failed valid-event repair write must fail closed, never truncate history'); + } finally { + fs.writeSync = originalWrite; + } + assert.equal(injected, 1, 'Fault injection must reach the actual canonical repair write'); + assert.deepEqual(fs.readFileSync(paths.events), validWithoutNewline, + 'A failed newline repair must preserve the complete valid event bytes'); +}); + + +test('T04-H61 failed lock metadata fsync must not strand an orphaned lock', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H61_BASELINE', payload: { step: 1 } }); + const paths = getStateEnginePaths(rootDir); + const before = fs.readFileSync(paths.events); + const originalFsync = fs.fsyncSync; + let injected = false; + fs.fsyncSync = function denyNewLockDurability(fd, ...args) { + const opened = fs.fstatSync(fd, { bigint: true }); + let entry = null; + try { + entry = fs.lstatSync(paths.lock, { bigint: true }); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + if (!injected && entry && opened.isFile() && + opened.dev === entry.dev && opened.ino === entry.ino) { + injected = true; + const error = new Error('injected lock-file fsync failure'); + error.code = 'EIO'; + throw error; + } + return originalFsync.call(this, fd, ...args); + }; + try { + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'H61_REJECTED', payload: { step: 2 } }), + /lock|EIO|durability/i, + ); + } finally { + fs.fsyncSync = originalFsync; + } + assert.equal(injected, true, 'Must exercise failure after exclusive lock-file creation'); + assert.equal(fs.existsSync(paths.lock), false, + 'A failed lock initialization must not strand an unowned lock file'); + assert.deepEqual(fs.readFileSync(paths.events), before, + 'Lock initialization failure must not mutate canonical history'); + appendMetadataEvent({ rootDir, eventType: 'H61_RETRY', payload: { step: 3 } }); + assert.equal(loadCanonicalEvents(rootDir).length, 2); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + + +test('T04-H61 lock metadata write failure must not leave an unowned lock', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H61_WRITE_BASELINE', payload: { step: 1 } }); + const paths = getStateEnginePaths(rootDir); + const before = fs.readFileSync(paths.events); + const originalWriteFile = fs.writeFileSync; + let injected = false; + fs.writeFileSync = function denyLockMetadataWrite(target, ...args) { + if (!injected && typeof target === 'number') { + const opened = fs.fstatSync(target, { bigint: true }); + let lockEntry = null; + try { + lockEntry = fs.lstatSync(paths.lock, { bigint: true }); + } catch (error) { + if (error.code !== 'ENOENT') throw error; + } + if (lockEntry && opened.isFile() && + opened.dev === lockEntry.dev && opened.ino === lockEntry.ino) { + injected = true; + const error = new Error('injected lock-file write failure'); + error.code = 'EIO'; + throw error; + } + } + return originalWriteFile.call(this, target, ...args); + }; + try { + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'H61_WRITE_REJECTED', payload: { step: 2 } }), + /lock|EIO|write/i, + ); + } finally { + fs.writeFileSync = originalWriteFile; + } + assert.equal(injected, true, 'Must inject after exclusive lock-file creation'); + assert.equal(fs.existsSync(paths.lock), false, + 'Failed lock metadata write must not strand an unowned lock'); + assert.deepEqual(fs.readFileSync(paths.events), before); + appendMetadataEvent({ rootDir, eventType: 'H61_WRITE_RETRY', payload: { step: 3 } }); + assert.equal(loadCanonicalEvents(rootDir).length, 2); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + + +test('T04-H62 failed state-lock release must not silently acknowledge a durable append', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H62_BASELINE', payload: { step: 1 } }); + const paths = getStateEnginePaths(rootDir); + const originalUnlink = fs.unlinkSync; + let injected = false; + fs.unlinkSync = function failLockRelease(target, ...args) { + if (!injected && path.resolve(String(target)) === path.resolve(paths.lock)) { + injected = true; + const error = new Error('injected state-lock release unlink EIO'); + error.code = 'EIO'; + throw error; + } + return originalUnlink.call(this, target, ...args); + }; + try { + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'H62_COMMITTED', payload: { step: 2 } }), + /lock release|offline recovery|EIO/i, + 'A failed release of the owned lock must never return an unqualified success', + ); + } finally { + fs.unlinkSync = originalUnlink; + } + assert.equal(injected, true, 'Fault must hit the actual state-lock release'); + assert.equal(fs.existsSync(paths.lock), true, 'Failed lock release must preserve its owner witness'); + assert.equal(loadCanonicalEvents(rootDir).length, 2, + 'A durable canonical append must not be rolled back or duplicated after release failure'); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + + +for (const deniedCode of ['EACCES', 'EIO']) { + test(`T04-H63 unreadable acknowledged snapshot blocks derived repair (${deniedCode})`, (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H63_BASELINE', payload: { step: 1 } }); + appendMetadataEvent({ rootDir, eventType: 'H63_ACKNOWLEDGED', payload: { step: 2 } }); + const paths = getStateEnginePaths(rootDir); + const snapshotWitness = fs.readFileSync(paths.snapshot); + const indexWitness = fs.readFileSync(paths.index); + const canonical = fs.readFileSync(paths.events, 'utf8'); + const firstLine = canonical.slice(0, canonical.indexOf('\n') + 1); + fs.writeFileSync(paths.events, firstLine); + const truncatedCanonical = fs.readFileSync(paths.events); + assert.equal(fs.existsSync(paths.pending), false); + + const originalRead = fs.readFileSync; + let injected = 0; + fs.readFileSync = function denyDerivedSnapshotWitness(target, ...args) { + if (String(target) === paths.snapshot) { + injected += 1; + const error = new Error(`Synthetic ${deniedCode} reading acknowledged snapshot`); + error.code = deniedCode; + throw error; + } + return originalRead.call(this, target, ...args); + }; + try { + assert.throws(() => rebuildStateSnapshot(rootDir), + /snapshot witness|EACCES|EIO|cannot inspect/i, + 'A failed witness read must not authorize replacement by an older ledger prefix'); + } finally { + fs.readFileSync = originalRead; + } + assert.ok(injected > 0, 'Fault injection must reach the actual snapshot witness read'); + assert.deepEqual(fs.readFileSync(paths.snapshot), snapshotWitness, + 'The acknowledged snapshot witness must survive failed inspection byte-for-byte'); + assert.deepEqual(fs.readFileSync(paths.index), indexWitness, + 'The derived index must not be rewritten after witness inspection fails'); + assert.deepEqual(fs.readFileSync(paths.events), truncatedCanonical, + 'The canonical ledger must not be modified by rejected derived repair'); + assert.equal(fs.existsSync(paths.pending), false); + }); +} + + +test('T04-H64 a missing canonical ledger cannot be silently recreated over acknowledged snapshot history', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H64_ACKNOWLEDGED', payload: { critical: true } }); + const paths = getStateEnginePaths(rootDir); + const canonicalWitness = fs.readFileSync(paths.events); + const snapshotWitness = fs.readFileSync(paths.snapshot); + const indexWitness = fs.readFileSync(paths.index); + assert.ok(canonicalWitness.length > 0); + assert.ok(JSON.parse(snapshotWitness.toString('utf8')).lastEventSequence > 0); + + // Model loss of the directory entry after a completed, acknowledged commit. + // No pending journal is required to prove this is not a fresh project: + // the durable snapshot/index are existing witnesses of committed history. + fs.unlinkSync(paths.events); + assert.equal(fs.existsSync(paths.pending), false); + + for (const [label, action] of [ + ['canonical reader', () => loadCanonicalEvents(rootDir)], + ['snapshot reader', () => loadStateSnapshot(rootDir)], + ['layout initializer', () => ensureStateEngineLayout(rootDir)], + ['new writer', () => appendMetadataEvent({ + rootDir, eventType: 'H64_MUST_NOT_REPLACE_HISTORY', payload: { critical: false }, + })], + ]) { + assert.throws(action, /missing|absent|incomplete|integrity|canonical|ledger/i, + label + ' must fail closed instead of accepting an empty canonical history'); + assert.equal(fs.existsSync(paths.events), false, + label + ' must not manufacture a replacement canonical ledger'); + assert.deepEqual(fs.readFileSync(paths.snapshot), snapshotWitness); + assert.deepEqual(fs.readFileSync(paths.index), indexWitness); + } + + // The original canonical bytes remain sufficient to restore the authority + // through a separate, evidence-preserving operator recovery operation. + fs.writeFileSync(paths.events, canonicalWitness); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-H65 valid canonical prefix truncation cannot overwrite acknowledged snapshot history', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H65_FIRST', payload: { order: 1 } }); + appendMetadataEvent({ rootDir, eventType: 'H65_ACKNOWLEDGED', payload: { order: 2 } }); + const paths = getStateEnginePaths(rootDir); + const fullLedger = fs.readFileSync(paths.events, 'utf8'); + const snapshotWitness = fs.readFileSync(paths.snapshot); + const indexWitness = fs.readFileSync(paths.index); + const prefix = fullLedger.slice(0, fullLedger.indexOf('\n') + 1); + fs.writeFileSync(paths.events, prefix, 'utf8'); + assert.equal(fs.existsSync(paths.pending), false); + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'H65_MUST_NOT_REPLACE', payload: { order: 999 } }), + /canonical|witness|history|truncat|integrity/i, + 'A new valid event must not replace an already acknowledged event with the same sequence', + ); + assert.equal(fs.readFileSync(paths.events, 'utf8'), prefix); + assert.deepEqual(fs.readFileSync(paths.snapshot), snapshotWitness); + assert.deepEqual(fs.readFileSync(paths.index), indexWitness); + assert.equal(fs.existsSync(paths.pending), false); +}); + +test('T04-H65 an index-only witness blocks derived repair and a new entity write over truncated history', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H65_FIRST', payload: { order: 1 } }); + appendMetadataEvent({ rootDir, eventType: 'H65_ACKNOWLEDGED', payload: { order: 2 } }); + const paths = getStateEnginePaths(rootDir); + const fullLedger = fs.readFileSync(paths.events, 'utf8'); + const indexWitness = fs.readFileSync(paths.index); + const prefix = fullLedger.slice(0, fullLedger.indexOf('\n') + 1); + fs.writeFileSync(paths.events, prefix, 'utf8'); + fs.unlinkSync(paths.snapshot); + assert.throws( + () => appendEntityState({ + rootDir, entityType: 'h65-state', entityId: 'test', state: { stateRevision: 1, value: 'must-not-write' }, + }), + /canonical|witness|history|truncat|integrity/i, + 'A retained index is an acknowledged history witness even when the snapshot is missing', + ); + assert.equal(fs.readFileSync(paths.events, 'utf8'), prefix); + assert.deepEqual(fs.readFileSync(paths.index), indexWitness); + // Layout bootstrap may recreate a disposable empty snapshot before it + // inspects the retained index; it must not publish the truncated history. + if (fs.existsSync(paths.snapshot)) { + assert.equal(JSON.parse(fs.readFileSync(paths.snapshot, 'utf8')).lastEventSequence, 0); + } + assert.equal(fs.existsSync(paths.pending), false); +}); + +test('T04-H65 older but consistent derived witnesses may be refreshed by a later valid append', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H65_FIRST', payload: { order: 1 } }); + const paths = getStateEnginePaths(rootDir); + const oldSnapshot = fs.readFileSync(paths.snapshot); + const oldIndex = fs.readFileSync(paths.index); + appendMetadataEvent({ rootDir, eventType: 'H65_SECOND', payload: { order: 2 } }); + fs.writeFileSync(paths.snapshot, oldSnapshot); + fs.writeFileSync(paths.index, oldIndex); + appendMetadataEvent({ rootDir, eventType: 'H65_THIRD', payload: { order: 3 } }); + assert.equal(loadCanonicalEvents(rootDir).length, 3); + assert.equal(JSON.parse(fs.readFileSync(paths.snapshot, 'utf8')).lastEventSequence, 3); + assert.equal(JSON.parse(fs.readFileSync(paths.index, 'utf8')).sourceSequence, 3); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-H65 a failed snapshot witness read cannot authorize a metadata append', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H65_FIRST', payload: { order: 1 } }); + const paths = getStateEnginePaths(rootDir); + const originalLedger = fs.readFileSync(paths.events); + const originalIndex = fs.readFileSync(paths.index); + const originalRead = fs.readFileSync; + let denied = false; + fs.readFileSync = function rejectSnapshotWitnessRead(target, ...args) { + if (String(target) === paths.snapshot) { + denied = true; + const error = new Error('Injected snapshot witness EIO'); + error.code = 'EIO'; + throw error; + } + return originalRead.call(this, target, ...args); + }; + try { + assert.throws( + () => appendMetadataEvent({ rootDir, eventType: 'H65_MUST_NOT_COMMIT', payload: { order: 2 } }), + /snapshot|witness|EIO|inspect/i, + ); + } finally { + fs.readFileSync = originalRead; + } + assert.equal(denied, true, 'Writer must actually inspect the acknowledged snapshot witness'); + assert.deepEqual(fs.readFileSync(paths.events), originalLedger); + assert.deepEqual(fs.readFileSync(paths.index), originalIndex); + assert.equal(fs.existsSync(paths.pending), false); +}); + +test('T04-H65 index-only acknowledged history prevents destructive torn-tail recovery', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H65_FIRST', payload: { order: 1 } }); + appendMetadataEvent({ rootDir, eventType: 'H65_ACKNOWLEDGED', payload: { order: 2 } }); + const paths = getStateEnginePaths(rootDir); + const ledger = fs.readFileSync(paths.events, 'utf8'); + const indexWitness = fs.readFileSync(paths.index); + const first = ledger.slice(0, ledger.indexOf('\n') + 1); + const torn = first + '{"schemaVersion":"1.0.0","sequence":2,'; + fs.writeFileSync(paths.events, torn, 'utf8'); + fs.unlinkSync(paths.snapshot); + assert.throws( + () => loadCanonicalEvents(rootDir), + /index|witness|canonical|truncat|history/i, + 'The surviving index must prevent discarding bytes potentially belonging to an acknowledged event', + ); + assert.equal(fs.readFileSync(paths.events, 'utf8'), torn); + assert.deepEqual(fs.readFileSync(paths.index), indexWitness); + assert.equal(fs.existsSync(paths.pending), false); +}); + +test('T04-H65 index-only witness rejects a conflicting valid pending-recovery suffix', (t) => { + const rootDir = tempProject(t); + const alternativeRoot = tempProject(t); + const at = '2026-10-31T00:00:00.000Z'; + for (const root of [rootDir, alternativeRoot]) { + appendMetadataEvent({ rootDir: root, eventType: 'H65_FIRST', payload: { order: 1 }, timestamp: at }); + } + appendMetadataEvent({ rootDir, eventType: 'H65_ACKNOWLEDGED', payload: { order: 2 }, timestamp: at }); + appendMetadataEvent({ + rootDir: alternativeRoot, eventType: 'H65_CONFLICTING', payload: { order: 999 }, timestamp: at, + }); + const paths = getStateEnginePaths(rootDir); + const fullLedger = fs.readFileSync(paths.events, 'utf8'); + const alternativeLedger = fs.readFileSync(getStateEnginePaths(alternativeRoot).events, 'utf8'); + const prefix = fullLedger.slice(0, fullLedger.indexOf('\n') + 1); + assert.ok(alternativeLedger.startsWith(prefix), 'Both histories must share the exact valid canonical prefix'); + const suffix = alternativeLedger.slice(prefix.length); + const indexWitness = fs.readFileSync(paths.index); + fs.writeFileSync(paths.events, prefix, 'utf8'); + fs.unlinkSync(paths.snapshot); + const pending = JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: Buffer.byteLength(prefix), + previousHash: 'sha256:' + createHash('sha256').update(prefix).digest('hex'), + suffix, + }); + fs.writeFileSync(paths.pending, pending, 'utf8'); + assert.throws( + () => loadCanonicalEvents(rootDir), + /index|witness|canonical|history|conflict|hash/i, + 'A hash-valid but different event must not supersede acknowledged index history', + ); + assert.equal(fs.readFileSync(paths.events, 'utf8'), prefix); + assert.deepEqual(fs.readFileSync(paths.index), indexWitness); + assert.equal(fs.readFileSync(paths.pending, 'utf8'), pending); +}); + +test('T04-H66 index rebuild cannot erase a later acknowledged witness behind a valid prefix', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H66_FIRST', payload: { order: 1 } }); + const paths = getStateEnginePaths(rootDir); + const prefix = fs.readFileSync(paths.events, 'utf8'); + const oldSnapshot = fs.readFileSync(paths.snapshot); + appendMetadataEvent({ rootDir, eventType: 'H66_ACKNOWLEDGED', payload: { order: 2 } }); + const indexWitness = fs.readFileSync(paths.index); + fs.writeFileSync(paths.events, prefix, 'utf8'); + fs.writeFileSync(paths.snapshot, oldSnapshot); + assert.throws(() => loadStateIndex(rootDir), /index|witness|canonical|history|truncat|integrity/i, + 'Index repair must not overwrite a later acknowledged index with an older canonical prefix'); + assert.equal(fs.readFileSync(paths.events, 'utf8'), prefix); + assert.deepEqual(fs.readFileSync(paths.index), indexWitness); + assert.deepEqual(fs.readFileSync(paths.snapshot), oldSnapshot); +}); + +test('T04-H66 an unreadable index witness cannot authorize index replacement', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H66_BASELINE', payload: { order: 1 } }); + const paths = getStateEnginePaths(rootDir); + const indexWitness = fs.readFileSync(paths.index); + const ledgerWitness = fs.readFileSync(paths.events); + const originalRead = fs.readFileSync; + let denied = 0; + fs.readFileSync = function denyIndexRead(target, ...args) { + if (String(target) === paths.index) { + denied += 1; + const error = new Error('Synthetic index witness EIO'); + error.code = 'EIO'; + throw error; + } + return originalRead.call(this, target, ...args); + }; + try { + assert.throws(() => loadStateIndex(rootDir), /index|witness|EIO|inspect/i, + 'Read failure must not cause replacement of a potentially acknowledged index witness'); + } finally { + fs.readFileSync = originalRead; + } + assert.ok(denied > 0); + assert.deepEqual(fs.readFileSync(paths.index), indexWitness); + assert.deepEqual(fs.readFileSync(paths.events), ledgerWitness); +}); + +test('T04-H66 pending recovery must compare an older index witness to its exact proposed event prefix', (t) => { + const rootDir = tempProject(t); + const alternativeRoot = tempProject(t); + const at = '2026-10-31T00:00:00.000Z'; + for (const root of [rootDir, alternativeRoot]) { + appendMetadataEvent({ rootDir: root, eventType: 'H66_FIRST', payload: { order: 1 }, timestamp: at }); + } + appendMetadataEvent({ rootDir, eventType: 'H66_ACKNOWLEDGED', payload: { order: 2 }, timestamp: at }); + appendMetadataEvent({ rootDir: alternativeRoot, eventType: 'H66_CONFLICT', payload: { order: 99 }, timestamp: at }); + appendMetadataEvent({ rootDir: alternativeRoot, eventType: 'H66_THIRD', payload: { order: 3 }, timestamp: at }); + const paths = getStateEnginePaths(rootDir); + const ledger = fs.readFileSync(paths.events, 'utf8'); + const prefix = ledger.slice(0, ledger.indexOf('\n') + 1); + const alternativeLedger = fs.readFileSync(getStateEnginePaths(alternativeRoot).events, 'utf8'); + assert.ok(alternativeLedger.startsWith(prefix)); + const indexWitness = fs.readFileSync(paths.index); + fs.writeFileSync(paths.events, prefix); + fs.unlinkSync(paths.snapshot); + const pending = JSON.stringify({ + schemaVersion: '1.0.0', + previousByteLength: Buffer.byteLength(prefix), + previousHash: 'sha256:' + createHash('sha256').update(prefix).digest('hex'), + suffix: alternativeLedger.slice(prefix.length), + }); + fs.writeFileSync(paths.pending, pending); + assert.throws(() => loadCanonicalEvents(rootDir), /index|witness|canonical|history|conflict|hash/i, + 'Later proposed events cannot hide a conflict at the earlier acknowledged witness sequence'); + assert.equal(fs.readFileSync(paths.events, 'utf8'), prefix); + assert.deepEqual(fs.readFileSync(paths.index), indexWitness); + assert.equal(fs.readFileSync(paths.pending, 'utf8'), pending); +}); + +test('T04-H66 older index witness with a matching canonical event prefix remains rebuildable', (t) => { + const rootDir = tempProject(t); + appendMetadataEvent({ rootDir, eventType: 'H66_FIRST', payload: { order: 1 } }); + const paths = getStateEnginePaths(rootDir); + const oldIndex = fs.readFileSync(paths.index); + appendMetadataEvent({ rootDir, eventType: 'H66_SECOND', payload: { order: 2 } }); + appendMetadataEvent({ rootDir, eventType: 'H66_THIRD', payload: { order: 3 } }); + fs.writeFileSync(paths.index, oldIndex); + assert.equal(loadStateIndex(rootDir).sourceSequence, 3); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); diff --git a/scripts/state-engine-v2-migration.test.mjs b/scripts/state-engine-v2-migration.test.mjs new file mode 100644 index 000000000..3f1883ce6 --- /dev/null +++ b/scripts/state-engine-v2-migration.test.mjs @@ -0,0 +1,1657 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync as requireSpawnSync } from 'node:child_process'; + +import { + appendEntityState, + canonicalStateJson, + countStateEngineFiles, + getStateEnginePaths, + loadCanonicalEvents, + loadEntityState, + verifyStateEngineIntegrity, +} from '../runtime/orchestration/state-engine-v2.mjs'; +import { + getLegacyBackupPath, + inspectLegacyState, + migrateLegacyStateToV2, + restoreLegacyBackup, + recoverLegacyRestore, +} from '../runtime/orchestration/state-engine-migration.mjs'; +import { + getCurrentState, + recoverLatestValidState, + saveStateRevision, +} from '../runtime/autopilot/state-store.mjs'; +import { + loadCurrentRunState, +} from '../runtime/orchestration/orchestration-run.mjs'; +import { withMigrationGate } from '../runtime/orchestration/state-migration-gate.mjs'; + +function tempProject(t, prefix = 'dk-state-migration-') { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => fs.rmSync(rootDir, { recursive: true, force: true })); + return rootDir; +} + +function workflowState(revision, workflowId = 'wf-legacy-001') { + return { + schemaVersion: '1.0.0', + workflowId, + projectId: 'proj-legacy', + workspaceId: 'ws-legacy', + workflowMode: 'autopilot', + autonomyLevel: 'guided-autopilot', + workflowStatus: revision === 26 ? 'paused' : 'executing', + currentStage: revision >= 20 ? 'IMPLEMENT' : revision >= 10 ? 'DESIGN' : 'UNDERSTAND', + completedStages: revision >= 10 ? ['UNDERSTAND', 'DEFINE'] : [], + skippedStages: [], + blockedStages: [], + activeAction: null, + pendingApproval: null, + pendingConfirmation: null, + activeDecisionMenu: null, + stateRevision: revision, + createdAt: '2026-09-01T00:00:00.000Z', + updatedAt: new Date(Date.parse('2026-09-01T00:00:00.000Z') + revision * 1000).toISOString(), + frameworkVersion: '0.11.2', + }; +} + +function writeLegacyAutopilot(rootDir, count = 26) { + const stateDir = path.join(rootDir, '.development-kit', 'autopilot', 'state'); + fs.mkdirSync(stateDir, { recursive: true }); + const workflowId = 'wf-legacy-001'; + for (let revision = 1; revision <= count; revision += 1) { + const name = 'revision-' + String(revision).padStart(6, '0') + '.json'; + fs.writeFileSync( + path.join(stateDir, name), + JSON.stringify(workflowState(revision, workflowId), null, 2) + '\n', + 'utf8', + ); + } + fs.writeFileSync( + path.join(stateDir, 'current.json'), + JSON.stringify({ + currentRevision: count, + currentRevisionFile: 'revision-' + String(count).padStart(6, '0') + '.json', + workflowId, + updatedAt: '2026-09-01T01:00:00.000Z', + }, null, 2) + '\n', + 'utf8', + ); + return { stateDir, workflowId, current: workflowState(count, workflowId) }; +} + +function runState(revision, state = 'READY', acceptanceState = 'PENDING') { + return { + schemaVersion: '1.0.0', + contractId: 'INC-LEGACY-RUN', + taskId: 'TASK-LEGACY-RUN', + runId: 'run-legacy-001', + sourceFingerprint: 'sha256:' + 'b'.repeat(64), + createdAt: '2026-09-02T00:00:00.000Z', + updatedAt: new Date(Date.parse('2026-09-02T00:00:00.000Z') + revision * 1000).toISOString(), + stateRevision: revision, + state, + executionStrategy: 'sequential-fresh-context', + hostCapabilities: { + fileRead: true, + fileWrite: true, + shell: true, + git: true, + freshContext: true, + subagents: false, + parallelAgents: false, + browser: false, + visualInspection: false, + externalModelRouting: false, + }, + manualEvidenceRequired: false, + requiredGates: { + verification: ['specification', 'tests'], + reviewers: ['code-reviewer'], + controlDomains: [], + humanApprovals: [], + configurationReadiness: { required: false }, + }, + completedGates: state === 'ACCEPTED' ? ['specification', 'tests', 'code-reviewer'] : [], + correctionAttempt: 0, + failureSignatures: [], + verificationVerdict: state === 'ACCEPTED' ? 'PASS' : null, + acceptanceState, + }; +} + +function writeLegacyRun(rootDir) { + const run1 = runState(1, 'READY', 'PENDING'); + const run2 = runState(2, 'VERIFYING', 'PENDING'); + const run3 = runState(3, 'ACCEPTED', 'ACCEPTED'); + const runDir = path.join( + rootDir, + '.development-kit', + 'runs', + run1.contractId, + run1.runId, + ); + const revisions = path.join(runDir, 'state-revisions'); + fs.mkdirSync(revisions, { recursive: true }); + fs.writeFileSync(path.join(runDir, 'manifest.json'), JSON.stringify(run1, null, 2) + '\n', 'utf8'); + for (const run of [run1, run2, run3]) { + const name = String(run.stateRevision).padStart(8, '0') + '.json'; + fs.writeFileSync(path.join(revisions, name), JSON.stringify(run, null, 2) + '\n', 'utf8'); + } + fs.writeFileSync( + path.join(runDir, 'current-state.json'), + JSON.stringify({ + schemaVersion: '1.0.0', + contractId: run3.contractId, + runId: run3.runId, + stateRevision: run3.stateRevision, + revisionPath: 'state-revisions/00000003.json', + }, null, 2) + '\n', + 'utf8', + ); + fs.writeFileSync(path.join(runDir, 'final-state.json'), JSON.stringify(run3, null, 2) + '\n', 'utf8'); + return { runDir, current: run3 }; +} + +test('AC-015 unmigrated legacy Autopilot projects remain on legacy state until migration', (t) => { + const rootDir = tempProject(t); + const legacy = writeLegacyAutopilot(rootDir, 2); + assert.deepEqual(getCurrentState(rootDir), legacy.current); + + const third = workflowState(3, legacy.workflowId); + saveStateRevision(third, rootDir); + assert.equal( + fs.existsSync(path.join(legacy.stateDir, 'revision-000003.json')), + true, + ); + assert.deepEqual(getCurrentState(rootDir), third); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'state')), false); +}); + +test('AC-015 partial V2 import cannot cut over an existing legacy project before MIGRATION_COMPLETED', (t) => { + const rootDir = tempProject(t); + const legacy = writeLegacyAutopilot(rootDir, 2); + + const partial = workflowState(2, legacy.workflowId); + partial.currentStage = 'REVIEW'; + appendEntityState({ + rootDir, + entityType: 'autopilot-workflow', + entityId: legacy.workflowId, + state: partial, + actorClass: 'legacy-migration', + timestamp: partial.updatedAt, + }); + + // A partially imported V2 entity exists, but there is no verified + // MIGRATION_COMPLETED event. The legacy store must therefore remain canonical. + const beforeCompletion = getCurrentState(rootDir); + assert.equal(beforeCompletion.currentStage, legacy.current.currentStage); + assert.notEqual(beforeCompletion.currentStage, partial.currentStage); + + const nextLegacy = workflowState(3, legacy.workflowId); + saveStateRevision(nextLegacy, rootDir); + assert.equal( + fs.existsSync(path.join(legacy.stateDir, 'revision-000003.json')), + true, + ); + assert.equal(getCurrentState(rootDir).stateRevision, 3); +}); + +test('AC-015 legacy migration is idempotent, semantically equivalent, and retains recoverable legacy state', (t) => { + const rootDir = tempProject(t); + const legacy = writeLegacyAutopilot(rootDir, 26); + const inspection = inspectLegacyState(rootDir); + assert.equal(inspection.legacyFileCount, 27); + assert.equal(inspection.entityCount, 1); + + const first = migrateLegacyStateToV2({ + rootDir, + completedAt: '2026-10-07T18:30:00.000Z', + }); + assert.equal(first.migrated, true); + assert.equal(first.idempotent, false); + assert.equal(first.semanticEquivalence, true); + assert.equal(first.legacyRetained, true); + assert.equal(canonicalStateJson(getCurrentState(rootDir)), canonicalStateJson(legacy.current)); + assert.equal(canonicalStateJson( + loadEntityState('autopilot-workflow', legacy.workflowId, rootDir), + ), canonicalStateJson(legacy.current)); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); + + for (let revision = 1; revision <= 26; revision += 1) { + assert.equal( + fs.existsSync(path.join( + legacy.stateDir, + 'revision-' + String(revision).padStart(6, '0') + '.json', + )), + true, + ); + } + assert.equal(fs.existsSync(path.join(legacy.stateDir, 'current.json')), true); + assert.equal(fs.existsSync(getLegacyBackupPath(rootDir)), true); + + const eventsBefore = loadCanonicalEvents(rootDir).length; + const second = migrateLegacyStateToV2({ + rootDir, + completedAt: '2026-10-07T18:31:00.000Z', + }); + assert.equal(second.migrated, false); + assert.equal(second.idempotent, true); + assert.equal(loadCanonicalEvents(rootDir).length, eventsBefore); +}); + +test('AC-015 completed migration remains idempotent after legitimate V2 progress', (t) => { + const rootDir = tempProject(t); + const legacy = writeLegacyAutopilot(rootDir, 3); + const first = migrateLegacyStateToV2({ + rootDir, + completedAt: '2026-10-07T18:31:30.000Z', + }); + assert.equal(first.migrated, true); + + const progressed = workflowState(4, legacy.workflowId); + progressed.currentStage = 'VERIFY'; + appendEntityState({ + rootDir, + entityType: 'autopilot-workflow', + entityId: legacy.workflowId, + state: progressed, + actorClass: 'autopilot', + timestamp: progressed.updatedAt, + }); + + const eventCountBefore = loadCanonicalEvents(rootDir).length; + const rerun = migrateLegacyStateToV2({ + rootDir, + completedAt: '2026-10-07T18:31:40.000Z', + }); + + assert.equal(rerun.migrated, false); + assert.equal(rerun.idempotent, true); + assert.equal(loadCanonicalEvents(rootDir).length, eventCountBefore); + assert.equal(getCurrentState(rootDir).stateRevision, 4); + assert.equal(getCurrentState(rootDir).currentStage, 'VERIFY'); +}); + +test('AC-015 bundled legacy backup can restore the complete legacy state chain', (t) => { + const rootDir = tempProject(t); + writeLegacyAutopilot(rootDir, 5); + migrateLegacyStateToV2({ + rootDir, + completedAt: '2026-10-07T18:32:00.000Z', + }); + + const restoreRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'dk-state-restore-')); + t.after(() => fs.rmSync(restoreRoot, { recursive: true, force: true })); + const restored = restoreLegacyBackup({ confirmOffline: true, + backupPath: getLegacyBackupPath(rootDir), + targetRoot: restoreRoot, + }); + assert.equal(restored.restoredFiles.length, 6); + + for (let revision = 1; revision <= 5; revision += 1) { + assert.equal(fs.existsSync(path.join( + restoreRoot, + '.development-kit', + 'autopilot', + 'state', + 'revision-' + String(revision).padStart(6, '0') + '.json', + )), true); + } + assert.equal(fs.existsSync(path.join( + restoreRoot, + '.development-kit', + 'autopilot', + 'state', + 'current.json', + )), true); +}); + +test('AC-015 missing Autopilot revision fails closed before migration cutover', (t) => { + const rootDir = tempProject(t); + const legacy = writeLegacyAutopilot(rootDir, 4); + fs.rmSync(path.join(legacy.stateDir, 'revision-000002.json')); + + assert.throws( + () => migrateLegacyStateToV2({ rootDir }), + /revision chain is missing revision-000002\.json/, + ); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'state')), false); +}); + +test('AC-015 missing orchestration revision and manifest mismatch fail closed', (t) => { + const rootDir = tempProject(t); + const legacy = writeLegacyRun(rootDir); + fs.rmSync(path.join(legacy.runDir, 'state-revisions', '00000002.json')); + + assert.throws( + () => migrateLegacyStateToV2({ rootDir }), + /revision chain is missing 00000002\.json/, + ); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'state')), false); + + // Restore revision 2 and then corrupt revision 1 relative to manifest. + const run2 = runState(2, 'VERIFYING', 'PENDING'); + fs.writeFileSync( + path.join(legacy.runDir, 'state-revisions', '00000002.json'), + JSON.stringify(run2, null, 2) + '\n', + 'utf8', + ); + const run1 = runState(1, 'IMPLEMENTING', 'PENDING'); + fs.writeFileSync( + path.join(legacy.runDir, 'state-revisions', '00000001.json'), + JSON.stringify(run1, null, 2) + '\n', + 'utf8', + ); + + assert.throws( + () => migrateLegacyStateToV2({ rootDir }), + /revision 1 differs from immutable manifest/, + ); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'state')), false); +}); + +test('AC-015 corrupted legacy input fails closed before State Engine cutover', (t) => { + const rootDir = tempProject(t); + const legacy = writeLegacyAutopilot(rootDir, 3); + fs.writeFileSync(path.join(legacy.stateDir, 'revision-000002.json'), '{"broken":', 'utf8'); + + assert.throws( + () => migrateLegacyStateToV2({ rootDir }), + /invalid JSON/, + ); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'state')), false); + assert.equal(fs.existsSync(path.join(legacy.stateDir, 'current.json')), true); +}); + +test('AC-015 partial orchestration import cannot override legacy current state before verified migration completion', (t) => { + const rootDir = tempProject(t); + const legacy = writeLegacyRun(rootDir); + + const partial = structuredClone(legacy.current); + partial.state = 'BLOCKED'; + partial.acceptanceState = 'BLOCKED'; + appendEntityState({ + rootDir, + entityType: 'orchestration-run', + entityId: partial.contractId + '/' + partial.runId, + state: partial, + refs: { + contractId: partial.contractId, + taskId: partial.taskId, + runId: partial.runId, + }, + actorClass: 'legacy-migration', + timestamp: partial.updatedAt, + }); + + const current = loadCurrentRunState( + legacy.current.contractId, + legacy.current.runId, + rootDir, + ); + assert.equal(current.state, 'ACCEPTED'); + assert.equal(current.acceptanceState, 'ACCEPTED'); +}); + +test('AC-015 orchestration migration preserves ACCEPTED meaning and switches current-state reads to V2', (t) => { + const rootDir = tempProject(t); + const legacy = writeLegacyRun(rootDir); + const before = loadCurrentRunState( + legacy.current.contractId, + legacy.current.runId, + rootDir, + ); + assert.equal(before.state, 'ACCEPTED'); + assert.equal(before.acceptanceState, 'ACCEPTED'); + + const migrated = migrateLegacyStateToV2({ + rootDir, + completedAt: '2026-10-07T18:33:00.000Z', + }); + assert.equal(migrated.semanticEquivalence, true); + + const v2 = loadEntityState( + 'orchestration-run', + legacy.current.contractId + '/' + legacy.current.runId, + rootDir, + ); + assert.equal(canonicalStateJson(v2), canonicalStateJson(legacy.current)); + + const after = loadCurrentRunState( + legacy.current.contractId, + legacy.current.runId, + rootDir, + ); + assert.equal(after.state, 'ACCEPTED'); + assert.equal(after.acceptanceState, 'ACCEPTED'); + assert.equal(after.verificationVerdict, 'PASS'); + + assert.equal(fs.existsSync(path.join(legacy.runDir, 'state-revisions', '00000003.json')), true); + assert.equal(fs.existsSync(path.join(legacy.runDir, 'current-state.json')), true); + assert.equal(fs.existsSync(path.join(legacy.runDir, 'final-state.json')), true); +}); + +test('AC-034 representative 26-revision migration exceeds the >=80% active canonical micro-file reduction objective', (t) => { + const rootDir = tempProject(t); + writeLegacyAutopilot(rootDir, 26); + const legacyCount = inspectLegacyState(rootDir).legacyFileCount; + assert.equal(legacyCount, 27); + + migrateLegacyStateToV2({ + rootDir, + completedAt: '2026-10-07T18:34:00.000Z', + }); + + const canonicalFiles = countStateEngineFiles(rootDir); + const reductionPercent = Number((((legacyCount - canonicalFiles) / legacyCount) * 100).toFixed(2)); + process.stdout.write('T04_STATE_FILE_EVIDENCE ' + JSON.stringify({ + legacyTransitionStateFiles: legacyCount, + stateEngineCanonicalFilesIncludingRecoveryBundle: canonicalFiles, + reductionPercent, + legacyFilesRetainedForRecovery: true, + }) + '\n'); + + assert.equal(canonicalFiles, 5); + assert.equal(reductionPercent, 81.48); + assert.ok(reductionPercent >= 80); + + const paths = getStateEnginePaths(rootDir); + for (const required of ['events', 'snapshot', 'index', 'schema']) { + assert.equal(fs.existsSync(paths[required]), true); + } + assert.equal(fs.existsSync(getLegacyBackupPath(rootDir)), true); +}); + + +test('T04-H05 restore refuses a preexisting symlink/junction parent and leaves outside directory untouched', (t) => { + const rootDir = tempProject(t, 'dk-restore-source-'); + writeLegacyAutopilot(rootDir, 2); + const migrated = migrateLegacyStateToV2({ rootDir }); + const restoreRoot = tempProject(t, 'dk-restore-target-'); + const outside = tempProject(t, 'dk-restore-outside-'); + const dkRoot = path.join(restoreRoot, '.development-kit'); + fs.mkdirSync(dkRoot, { recursive: true }); + try { + fs.symlinkSync(outside, path.join(dkRoot, 'autopilot'), process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { + t.skip('Host does not permit directory symlink/junction creation'); + return; + } + throw error; + } + assert.throws( + () => restoreLegacyBackup({ confirmOffline: true, backupPath: migrated.backupPath, targetRoot: restoreRoot }), + /symbolic link|junction|escapes/i, + ); + assert.deepEqual(fs.readdirSync(outside), []); +}); + +test('T04-H06 restore refuses a final-file symlink even when overwrite is explicitly enabled', (t) => { + const rootDir = tempProject(t, 'dk-restore-source-'); + writeLegacyAutopilot(rootDir, 2); + const migrated = migrateLegacyStateToV2({ rootDir }); + const restoreRoot = tempProject(t, 'dk-restore-target-'); + const outside = tempProject(t, 'dk-restore-outside-'); + const filename = path.join(outside, 'should-not-change.json'); + fs.writeFileSync(filename, 'SAFE', 'utf8'); + const parent = path.join(restoreRoot, '.development-kit', 'autopilot', 'state'); + fs.mkdirSync(parent, { recursive: true }); + try { + fs.symlinkSync(filename, path.join(parent, 'revision-000001.json'), 'file'); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { + t.skip('Host does not permit file symlink creation'); + return; + } + throw error; + } + assert.throws( + () => restoreLegacyBackup({ confirmOffline: true, backupPath: migrated.backupPath, targetRoot: restoreRoot, overwrite: true }), + /regular file|symbolic link|junction/i, + ); + assert.equal(fs.readFileSync(filename, 'utf8'), 'SAFE'); +}); + + +test('T04-H13 backup restore rejects a hard-linked overwrite destination', (t) => { + const rootDir = tempProject(t, 'dk-hardlink-source-'); + writeLegacyAutopilot(rootDir, 2); + const migrated = migrateLegacyStateToV2({ rootDir }); + const restoreRoot = tempProject(t, 'dk-hardlink-target-'); + const outside = tempProject(t, 'dk-hardlink-outside-'); + const external = path.join(outside, 'protected.json'); + fs.writeFileSync(external, 'PROTECTED', 'utf8'); + const destinationDir = path.join(restoreRoot, '.development-kit', 'autopilot', 'state'); + fs.mkdirSync(destinationDir, { recursive: true }); + const destination = path.join(destinationDir, 'revision-000001.json'); + try { + fs.linkSync(external, destination); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOTSUP', 'EXDEV'].includes(error.code)) { + t.skip('Filesystem disallows hard-link fixture'); + return; + } + throw error; + } + assert.throws( + () => restoreLegacyBackup({ confirmOffline: true, backupPath: migrated.backupPath, targetRoot: restoreRoot, overwrite: true }), + /hard.?link|multiple links/i, + ); + assert.equal(fs.readFileSync(external, 'utf8'), 'PROTECTED'); +}); + + +test('T04-H19 restoring a backup cannot write through a destination hardlink swapped at open time', (t) => { + const source = tempProject(t, 'dk-restore-race-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const targetRoot = tempProject(t, 'dk-restore-race-target-'); + const protectedRoot = tempProject(t, 'dk-restore-race-outside-'); + const victim = path.join(protectedRoot, 'external.json'); + fs.writeFileSync(victim, 'EXTERNAL-CONTENT', 'utf8'); + const parent = path.join(targetRoot, '.development-kit', 'autopilot', 'state'); + fs.mkdirSync(parent, {recursive:true}); + const destination = path.join(parent, 'revision-000001.json'); + fs.writeFileSync(destination, 'stale value', 'utf8'); + const originalOpen = fs.openSync; + let swapped = false; + fs.openSync = function swappedAtDestinationOpen(file, flags, ...rest) { + if (!swapped && file === destination) { + swapped = true; + fs.rmSync(destination); + fs.linkSync(victim, destination); + } + return originalOpen.call(this, file, flags, ...rest); + }; + try { + const restored = restoreLegacyBackup({ confirmOffline: true, backupPath:migrated.backupPath, targetRoot, overwrite:true }); + assert.ok(restored.restoredFiles.includes('.development-kit/autopilot/state/revision-000001.json')); + } finally { + fs.openSync = originalOpen; + } + assert.equal(fs.readFileSync(victim, 'utf8'), 'EXTERNAL-CONTENT'); + assert.equal(JSON.parse(fs.readFileSync(destination, 'utf8')).stateRevision, 1); +}); + + +test('T04-H20 restore never creates target-tree directories before atomic staged promotion', (t) => { + const source = tempProject(t, 'dk-restore-stage-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const targetRoot = tempProject(t, 'dk-restore-stage-target-'); + const originalMkdir = fs.mkdirSync; + let attemptedUnsafeWrite = false; + fs.mkdirSync = function blockPrepromotionTargetWrites(directory, ...args) { + const relative = path.relative(targetRoot, path.resolve(directory)); + if (relative && relative !== '..' && !relative.startsWith('..' + path.sep) + && !path.isAbsolute(relative)) { + attemptedUnsafeWrite = true; + throw new Error('restore preflight attempted a direct target-directory mutation'); + } + return originalMkdir.call(this, directory, ...args); + }; + try { + const restored = restoreLegacyBackup({ confirmOffline: true, backupPath: migrated.backupPath, targetRoot }); + assert.equal(restored.restoredFiles.length, 3); + } finally { + fs.mkdirSync = originalMkdir; + } + assert.equal(attemptedUnsafeWrite, false); + const pathToRestored = path.join(targetRoot, '.development-kit', 'autopilot', 'state', 'revision-000001.json'); + assert.equal(JSON.parse(fs.readFileSync(pathToRestored, 'utf8')).stateRevision, 1); +}); + + +test('T04-H21 stage-promoted restore supports an absent root within an existing parent directory', (t) => { + const source = tempProject(t, 'dk-restore-newroot-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const parent = tempProject(t, 'dk-restore-newroot-parent-'); + const root = path.join(parent, 'new-restore-root'); + assert.equal(fs.existsSync(root), false); + const restored = restoreLegacyBackup({ confirmOffline: true, backupPath: migrated.backupPath, targetRoot: root }); + assert.equal(restored.restoredFiles.length, 3); + assert.equal( + JSON.parse(fs.readFileSync(path.join(root, '.development-kit','autopilot','state','revision-000001.json'), 'utf8')).stateRevision, + 1, + ); +}); + + +test('T04-H23 staged restore preserves the existing destination root mode and ownership', (t) => { + if (process.platform === 'win32') { + t.skip('POSIX mode/uid semantics are not portable to Windows'); return; + } + const source = tempProject(t, 'dk-mode-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const targetRoot = tempProject(t, 'dk-mode-target-'); + fs.chmodSync(targetRoot, 0o755); + const before = fs.statSync(targetRoot); + restoreLegacyBackup({ confirmOffline: true, backupPath: migrated.backupPath, targetRoot }); + const after = fs.statSync(targetRoot); + assert.equal(after.mode & 0o7777, before.mode & 0o7777); + assert.equal(after.uid, before.uid); + assert.equal(after.gid, before.gid); + assert.equal( + JSON.parse(fs.readFileSync(path.join(targetRoot,'.development-kit','autopilot','state','revision-000001.json'),'utf8')).stateRevision, + 1, + ); +}); + + +test('T04-H24 legacy restore requires explicit offline capability', (t) => { + const source = tempProject(t, 'dk-offline-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const root = tempProject(t, 'dk-offline-target-'); + assert.throws(() => restoreLegacyBackup({ backupPath: migrated.backupPath, targetRoot: root }), + /offline confirmation/i); + assert.deepEqual(fs.readdirSync(root), []); +}); + +test('T04-H25 interrupted two-step root promotion can restore original using journal', (t) => { + const source = tempProject(t, 'dk-crash-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const root = tempProject(t, 'dk-crash-target-'); + const sentinel = path.join(root, 'persist-original.txt'); + fs.writeFileSync(sentinel, 'ORIGINAL'); + const originalRename = fs.renameSync; + const originalWrite = fs.writeFileSync; + let movedRoot = false; + let failureInjected = false; + if (process.platform === 'win32') { + // Windows uses the native helper for renames. Inject immediately after + // the durable ORIGINAL_DISPLACED log write, before stage promotion. + fs.writeFileSync = function injectWindowsRestoreFailure(fd, content, ...args) { + const result = originalWrite.call(this, fd, content, ...args); + if (!failureInjected && typeof fd === 'number' && + String(content).includes('"phase":"ORIGINAL_DISPLACED"')) { + failureInjected = true; + throw new Error('simulated crash between displacement and promotion'); + } + return result; + }; + } else { + fs.renameSync = function injectPowerFailure(oldPath, nextPath, ...args) { + if (movedRoot && !failureInjected && typeof oldPath === 'string' && + oldPath.includes('.dk-legacy-restore-stage-') && nextPath === root) { + failureInjected = true; + throw new Error('simulated crash between displacement and promotion'); + } + if (oldPath === root && String(nextPath).endsWith('-original')) movedRoot = true; + return originalRename.call(this, oldPath, nextPath, ...args); + }; + } + try { + assert.throws(() => restoreLegacyBackup({ + confirmOffline: true, backupPath: migrated.backupPath, targetRoot: root, + }), /simulated crash/); + assert.equal(failureInjected, true, 'The intended crash boundary must actually be reached'); + } finally { + fs.renameSync = originalRename; + fs.writeFileSync = originalWrite; + } + // If the in-process finally managed the rollback, the leftover journal is + // still authoritative. Recovery must be idempotent and must never erase it. + assert.equal(fs.readFileSync(sentinel, 'utf8'), 'ORIGINAL'); + assert.throws(() => restoreLegacyBackup({ + confirmOffline:true, backupPath:migrated.backupPath, targetRoot:root, + }), /Incomplete legacy restore/i); + assert.throws(() => recoverLegacyRestore({ targetRoot: root }), /offline confirmation/i); + const recovered = recoverLegacyRestore({ targetRoot: root, confirmOffline: true }); + assert.equal(recovered.recovered, true); + assert.equal(fs.readFileSync(sentinel,'utf8'), 'ORIGINAL'); + assert.equal(recoverLegacyRestore({ targetRoot: root, confirmOffline: true }).recovered, false); + const restored = restoreLegacyBackup({ + confirmOffline:true, backupPath:migrated.backupPath, targetRoot:root, + }); + assert.equal(restored.restoredFiles.length, 3); + assert.equal(fs.readFileSync(path.join(root,'persist-original.txt'),'utf8'),'ORIGINAL'); +}); + + +test('T04-H28 restore journals preserve original after a simulated process death between root renames', (t) => { + const source = tempProject(t, 'dk-crash-worker-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const root = tempProject(t, 'dk-crash-worker-target-'); + const sentinel = path.join(root, 'original.txt'); + fs.writeFileSync(sentinel, 'KEEP-ME', 'utf8'); + + const script = [ + 'import fs from "node:fs";', + 'import {restoreLegacyBackup} from '+JSON.stringify(new URL('../runtime/orchestration/state-engine-migration.mjs', import.meta.url).href)+';', + 'const backup=process.argv[1],root=process.argv[2],rename=fs.renameSync,write=fs.writeFileSync;', + 'if(process.platform==="win32") {', + ' fs.writeFileSync=function(fd,content,...rest){', + ' const out=write.call(this,fd,content,...rest);', + ' if(typeof fd==="number" && String(content).includes(\'"phase":"ORIGINAL_DISPLACED"\')) process.exit(91);', + ' return out;', + ' };', + '} else {', + ' fs.renameSync=function(a,b,...rest){', + ' if(a===root && String(b).endsWith("-original")) {const out=rename.call(this,a,b,...rest);process.exit(91);}', + ' return rename.call(this,a,b,...rest);', + ' };', + '}', + 'restoreLegacyBackup({backupPath:backup,targetRoot:root,confirmOffline:true});', + ].join('\n'); + // spawnSync is intentionally used for a true process exit instead of a + // caught synchronous exception that runs finally/rollback. + const result = requireSpawnSync(process.execPath, ['--input-type=module','-e',script,migrated.backupPath,root]); + assert.equal(result.status, 91, result.stderr); + assert.equal(fs.existsSync(root), false); + assert.throws(() => restoreLegacyBackup({ + backupPath:migrated.backupPath,targetRoot:root,confirmOffline:true, + }), /Incomplete legacy restore/i); + const recovered = recoverLegacyRestore({ targetRoot: root, confirmOffline: true }); + assert.equal(recovered.outcome, 'rolled-back-original'); + assert.equal(fs.readFileSync(sentinel,'utf8'),'KEEP-ME'); + assert.equal(fs.existsSync(root),true); +}); + + +test('T04-H47 POSIX rollback parent flush precedes append-only terminal acknowledgement', (t) => { + if (process.platform === 'win32') { + t.skip('Native Windows rename/flush is exercised by H48'); return; + } + const source = tempProject(t, 'dk-rollback-fsync-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const root = tempProject(t, 'dk-rollback-fsync-target-'); + const sentinel = path.join(root, 'original.txt'); + fs.writeFileSync(sentinel, 'ROLLBACK-ORIGINAL', 'utf8'); + + const script = [ + 'import fs from "node:fs";', + 'import {restoreLegacyBackup} from ' + + JSON.stringify(new URL('../runtime/orchestration/state-engine-migration.mjs', import.meta.url).href) + ';', + 'const backup=process.argv[1],root=process.argv[2],rename=fs.renameSync;', + 'fs.renameSync=function(a,b,...rest){', + ' if(a===root && String(b).endsWith("-original")) {rename.call(this,a,b,...rest);process.exit(91);}', + ' return rename.call(this,a,b,...rest);', + '};', + 'restoreLegacyBackup({backupPath:backup,targetRoot:root,confirmOffline:true});', + ].join('\n'); + const result = requireSpawnSync(process.execPath, + ['--input-type=module', '-e', script, migrated.backupPath, root]); + assert.equal(result.status, 91, result.stderr); + assert.equal(fs.existsSync(root), false); + + const parent = path.dirname(root); + const parentStat = fs.statSync(parent); + const originalRename = fs.renameSync; + const originalFsync = fs.fsyncSync; + const originalWrite = fs.writeFileSync; + let rollbackRenamed = false; + let parentSyncedAfterRollback = false; + let terminalRecorded = false; + fs.renameSync = function watchRollback(from, to, ...args) { + const returned = originalRename.call(this, from, to, ...args); + if (typeof from === 'string' && from.endsWith('-original') && to === root) rollbackRenamed = true; + return returned; + }; + fs.fsyncSync = function watchParentDurability(fd) { + const stat = fs.fstatSync(fd); + const returned = originalFsync.call(this, fd); + if (rollbackRenamed && stat.isDirectory() && + stat.dev === parentStat.dev && stat.ino === parentStat.ino) parentSyncedAfterRollback = true; + return returned; + }; + fs.writeFileSync = function guardTerminalRecord(fd, content, ...args) { + if (typeof fd === 'number' && String(content).includes('"phase":"CLEANUP_SAFE"')) { + terminalRecorded = true; + assert.equal(rollbackRenamed, true, 'Recovery must restore the displaced root'); + assert.equal(parentSyncedAfterRollback, true, + 'Rollback directory rename must be fsynced before durable terminal log append'); + } + return originalWrite.call(this, fd, content, ...args); + }; + try { + const recovery = recoverLegacyRestore({ targetRoot: root, confirmOffline: true }); + assert.equal(recovery.outcome, 'rolled-back-original'); + assert.ok(terminalRecorded, 'Append-only terminal log must acknowledge rollback'); + assert.equal(fs.existsSync(recovery.transactionLog), true, 'Retain the hash-chained recovery log'); + assert.equal(fs.readFileSync(sentinel, 'utf8'), 'ROLLBACK-ORIGINAL'); + } finally { + fs.renameSync = originalRename; + fs.fsyncSync = originalFsync; + fs.writeFileSync = originalWrite; + } +}); + +test('T04-H48 Windows native rollback completes directory flush before retained terminal log', (t) => { + if (process.platform !== 'win32') { + t.skip('Windows native durability helper requires Windows NTFS'); return; + } + const source = tempProject(t, 'dk-win-rollback-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const root = tempProject(t, 'dk-win-rollback-target-'); + const sentinel = path.join(root, 'original.txt'); + fs.writeFileSync(sentinel, 'WINDOWS-ORIGINAL', 'utf8'); + + // The v0.13 Windows path uses an isolated native helper, not fs.renameSync. + // Kill the child only after the ORIGINAL_DISPLACED record has been written. + const script = [ + 'import fs from "node:fs";', + 'import {restoreLegacyBackup} from ' + + JSON.stringify(new URL('../runtime/orchestration/state-engine-migration.mjs', import.meta.url).href) + ';', + 'const backup=process.argv[1],root=process.argv[2],write=fs.writeFileSync;', + 'fs.writeFileSync=function(fd,content,...rest){', + ' const out=write.call(this,fd,content,...rest);', + ' if(typeof fd==="number" && String(content).includes(\'"phase":"ORIGINAL_DISPLACED"\')) process.exit(91);', + ' return out;', + '};', + 'restoreLegacyBackup({backupPath:backup,targetRoot:root,confirmOffline:true});', + ].join('\n'); + const result = requireSpawnSync(process.execPath, + ['--input-type=module', '-e', script, migrated.backupPath, root]); + assert.equal(result.status, 91, result.stderr); + assert.equal(fs.existsSync(root), false); + + const recovery = recoverLegacyRestore({ targetRoot: root, confirmOffline: true }); + assert.equal(recovery.outcome, 'rolled-back-original'); + assert.equal(fs.readFileSync(sentinel, 'utf8'), 'WINDOWS-ORIGINAL'); + assert.equal(fs.existsSync(recovery.transactionLog), true, 'Native recovery retains journal'); + const records = fs.readFileSync(recovery.transactionLog, 'utf8').trimEnd().split('\n').map(JSON.parse); + assert.equal(records.at(-1).phase, 'CLEANUP_SAFE'); + assert.equal(records.at(-1).outcome, 'rolled-back-original'); +}); + +test('T04-H30 offline staged restore preserves descendant file identity and POSIX permission metadata', (t) => { + if (process.platform === 'win32') { + t.skip('POSIX ownership and mode semantics require a POSIX host'); return; + } + const source = tempProject(t, 'dk-descendant-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const root = tempProject(t, 'dk-descendant-target-'); + const nested = path.join(root, 'unrelated', 'private.txt'); + fs.mkdirSync(path.dirname(nested), { recursive: true }); + fs.writeFileSync(nested, 'PROTECTED-CONTENT'); + fs.chmodSync(nested, 0o600); + if (process.getuid?.() === 0) { + fs.chownSync(nested, 65534, 65534); + } + const before = fs.statSync(nested); + restoreLegacyBackup({ backupPath:migrated.backupPath, targetRoot:root, confirmOffline:true }); + const after = fs.statSync(nested); + assert.equal(after.uid, before.uid); + assert.equal(after.gid, before.gid); + assert.equal(after.mode & 0o7777, before.mode & 0o7777); + assert.equal(fs.readFileSync(nested, 'utf8'), 'PROTECTED-CONTENT'); +}); + +test('T04-H31 staged restore does not advance historical root access and modification timestamps', (t) => { + if (process.platform === 'win32') { + t.skip('POSIX access-time semantics require a POSIX host'); return; + } + const source = tempProject(t, 'dk-atime-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const root = tempProject(t, 'dk-atime-target-'); + const old = new Date('2001-01-01T00:00:00.000Z'); + fs.utimesSync(root, old, old); + const before = fs.statSync(root); + restoreLegacyBackup({ backupPath:migrated.backupPath, targetRoot:root, confirmOffline:true }); + const after = fs.statSync(root); + assert.ok(Math.abs(after.atimeMs - before.atimeMs) < 1000, 'root access time must be preserved'); + assert.ok(Math.abs(after.mtimeMs - before.mtimeMs) < 1000, 'root modification time must be preserved'); +}); + + +test('T04-H32 offline staged restore preserves the text of unrelated relative symlinks', (t) => { + const source = tempProject(t, 'dk-link-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const targetRoot = tempProject(t, 'dk-link-target-'); + fs.writeFileSync(path.join(targetRoot, 'target.txt'), 'ORIGINAL', 'utf8'); + const link = path.join(targetRoot, 'relative-link.txt'); + try { + fs.symlinkSync('target.txt', link, 'file'); + } catch (error) { + if (['EPERM','EACCES','ENOTSUP'].includes(error.code)) { t.skip('Relative symlink unavailable'); return; } + throw error; + } + const priorLink = fs.readlinkSync(link); + restoreLegacyBackup({ backupPath: migrated.backupPath, targetRoot, confirmOffline: true }); + assert.equal(fs.readlinkSync(link), priorLink); + assert.equal(fs.readFileSync(link, 'utf8'), 'ORIGINAL'); +}); + +test('T04-H33 backup with duplicate normalized destinations is refused before staging', async (t) => { + const source = tempProject(t, 'dk-dup-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const targetRoot = tempProject(t, 'dk-dup-target-'); + const pathA = path.join(targetRoot, '.development-kit', 'autopilot', 'state', 'revision-000001.json'); + const backup = JSON.parse(fs.readFileSync(migrated.backupPath, 'utf8')); + const first = backup.files[0]; + const duplicate = { ...first, path: './' + first.path }; + backup.files.push(duplicate); + backup.fileCount = backup.files.length; + const manifest = backup.files.map(e => ({ path: e.path, fingerprint: e.fingerprint, bytes: e.bytes })); + const crypto = await import('node:crypto'); + function canonical(value) { + if (Array.isArray(value)) return value.map(canonical); + if (value && typeof value === 'object') return Object.fromEntries( + Object.entries(value).sort(([a],[b]) => a.localeCompare(b)).map(([key,val]) => [key, canonical(val)]) + ); + return value; + } + backup.sourceFingerprint = 'sha256:' + crypto.createHash('sha256').update(JSON.stringify(canonical(manifest))).digest('hex'); + const hostileBackup = path.join(source, 'alias-backup.json'); + fs.writeFileSync(hostileBackup, JSON.stringify(backup)); + assert.throws( + () => restoreLegacyBackup({ backupPath: hostileBackup, targetRoot, confirmOffline: true }), + /duplicate normalized/i, + ); + assert.equal(fs.existsSync(pathA), false); +}); + + +test('T04-H34 backup case aliases fail closed even on a case-sensitive test filesystem', async (t) => { + const source = tempProject(t, 'dk-restore-case-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const targetRoot = tempProject(t, 'dk-restore-case-target-'); + const backup = JSON.parse(fs.readFileSync(migrated.backupPath, 'utf8')); + const first = backup.files[0]; + const alias = { ...first, path: first.path.toUpperCase() }; + assert.notEqual(alias.path, first.path); + backup.files.push(alias); + backup.fileCount = backup.files.length; + const manifest = backup.files.map(e => ({ path: e.path, fingerprint: e.fingerprint, bytes: e.bytes })); + const { createHash } = await import('node:crypto'); + function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === 'object') return Object.fromEntries( + Object.keys(value).sort().map(key => [key, stable(value[key])])); + return value; + } + backup.sourceFingerprint = 'sha256:' + createHash('sha256').update(JSON.stringify(stable(manifest))).digest('hex'); + const aliasBackup = path.join(source, 'case-alias-backup.json'); + fs.writeFileSync(aliasBackup, JSON.stringify(backup)); + assert.throws( + () => restoreLegacyBackup({backupPath:aliasBackup,targetRoot,confirmOffline:true}), + /duplicate normalized/i, + ); + assert.deepEqual(fs.readdirSync(targetRoot), []); +}); + + +test('T04-H35 backup with distinct unpaired surrogates cannot overwrite one filesystem name', async (t) => { + const source = tempProject(t, 'dk-unicode-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const targetRoot = tempProject(t, 'dk-unicode-target-'); + const backup = JSON.parse(fs.readFileSync(migrated.backupPath, 'utf8')); + const sourceEntry = backup.files[0]; + backup.files.push({ ...sourceEntry, path: 'suspicious-\uD800.json' }); + backup.files.push({ ...sourceEntry, path: 'suspicious-\uD801.json' }); + backup.fileCount = backup.files.length; + const entries = backup.files.map(e => ({path:e.path, fingerprint:e.fingerprint, bytes:e.bytes})); + const { createHash } = await import('node:crypto'); + function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map(k => [k, stable(value[k])])); + } + return value; + } + backup.sourceFingerprint = 'sha256:' + createHash('sha256').update(JSON.stringify(stable(entries))).digest('hex'); + const hostileBackup = path.join(source, 'ill-formed-unicode.json'); + fs.writeFileSync(hostileBackup, JSON.stringify(backup)); + assert.throws( + () => restoreLegacyBackup({ backupPath: hostileBackup, targetRoot, confirmOffline: true }), + /ill-formed Unicode/i, + ); + assert.deepEqual(fs.readdirSync(targetRoot), []); +}); + +test('T04-H39 backup with trailing dot or space path alias is refused before restore', async (t) => { + const source = tempProject(t, 'dk-trailing-dot-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const targetRoot = tempProject(t, 'dk-trailing-dot-target-'); + const backup = JSON.parse(fs.readFileSync(migrated.backupPath, 'utf8')); + const sourceEntry = backup.files[0]; + // Attempt to add a trailing-dot alias path that would map to the same file on Windows + backup.files.push({ ...sourceEntry, path: sourceEntry.path + '.' }); + backup.fileCount = backup.files.length; + const entries = backup.files.map(e => ({ path: e.path, fingerprint: e.fingerprint, bytes: e.bytes })); + const { createHash } = await import('node:crypto'); + function stable(value) { + if (Array.isArray(value)) return value.map(stable); + if (value && typeof value === 'object') { + return Object.fromEntries(Object.keys(value).sort().map(k => [k, stable(value[k])])); + } + return value; + } + backup.sourceFingerprint = 'sha256:' + createHash('sha256').update(JSON.stringify(stable(entries))).digest('hex'); + const hostileBackup = path.join(source, 'trailing-dot-backup.json'); + fs.writeFileSync(hostileBackup, JSON.stringify(backup)); + assert.throws( + () => restoreLegacyBackup({ backupPath: hostileBackup, targetRoot, confirmOffline: true }), + /trailing dot|duplicate normalized/i, + ); + assert.deepEqual(fs.readdirSync(targetRoot), []); +}); + + +test('W73-26 Windows helper launch ignores a forged powershell.exe in the working directory', (t) => { + if (process.platform !== 'win32') { + t.skip('Windows executable-search hijack regression requires Windows'); return; + } + const source = tempProject(t, 'dk-win-helper-launch-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const hostileWorkingDir = tempProject(t, 'dk-win-helper-launch-cwd-'); + // A copy of Node named powershell.exe is an executable canary: invoking it + // with PowerShell flags fails. The restore must instead use the trusted + // system interpreter by absolute path, never libuv's cwd-first lookup. + fs.copyFileSync(process.execPath, path.join(hostileWorkingDir, 'powershell.exe')); + const targetRoot = path.join(hostileWorkingDir, 'restored-project'); + const script = [ + 'import { restoreLegacyBackup } from ' + + JSON.stringify(new URL('../runtime/orchestration/state-engine-migration.mjs', import.meta.url).href) + ';', + 'restoreLegacyBackup({ backupPath: process.argv[1], targetRoot: process.argv[2], confirmOffline: true });', + ].join('\n'); + const child = requireSpawnSync(process.execPath, + ['--input-type=module', '-e', script, migrated.backupPath, targetRoot], { + cwd: hostileWorkingDir, encoding: 'utf8', timeout: 120_000, windowsHide: true, + }); + assert.equal(child.status, 0, + 'Windows restore must not execute cwd/powershell.exe: ' + (child.stderr || child.stdout)); + assert.equal(fs.existsSync(path.join(targetRoot, '.development-kit', 'autopilot', 'state', 'current.json')), true, + 'A trusted helper launch must complete the original legacy backup restoration'); +}); + +test('W73-27 Windows 8.3 ancestor alias cannot bypass an interrupted restore journal', (t) => { + if (process.platform !== 'win32') { + t.skip('NTFS short-name aliases require Windows'); return; + } + const source = tempProject(t, 'dk-short-alias-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const parent = tempProject(t, 'dk-short-alias-parent-'); + const targetRoot = path.join(parent, 'target'); + fs.mkdirSync(targetRoot); + fs.writeFileSync(path.join(targetRoot, 'original.txt'), 'KEEP-ORIGINAL'); + const command = 'for %I in ("' + parent + '") do @echo %~sI'; + const shortResult = requireSpawnSync(process.env.ComSpec || 'cmd.exe', + ['/d', '/c', command], { encoding: 'utf8', windowsHide: true }); + assert.equal(shortResult.status, 0, shortResult.stderr); + const shortParent = shortResult.stdout.trim().split(/\r?\n/).at(-1); + let aliasParent = shortParent; + if (!fs.existsSync(shortParent) || + path.win32.normalize(shortParent).toLowerCase() === path.win32.normalize(parent).toLowerCase()) { + // The extended-length namespace is a second real spelling of the same + // Windows directory even when NTFS 8.3 name generation is disabled. + const extendedParent = '\\\\?\\' + parent; + if (!fs.existsSync(extendedParent)) { + t.skip('Neither an 8.3 nor an extended-length ancestor alias is available'); return; + } + aliasParent = extendedParent; + } + assert.equal(fs.realpathSync.native(aliasParent).toLowerCase(), + fs.realpathSync.native(parent).toLowerCase(), 'Alias must identify the same physical parent'); + const crashScript = [ + 'import fs from "node:fs";', + 'import {restoreLegacyBackup} from ' + + JSON.stringify(new URL('../runtime/orchestration/state-engine-migration.mjs', import.meta.url).href) + ';', + 'const write=fs.writeFileSync;', + 'fs.writeFileSync=function(fd, content, ...rest) {', + ' const result=write.call(this, fd, content, ...rest);', + ' if(typeof fd==="number" && String(content).includes(\'"phase":"PREPARED"\')) process.exit(91);', + ' return result;', + '};', + 'restoreLegacyBackup({backupPath:process.argv[1],targetRoot:process.argv[2],confirmOffline:true});', + ].join('\n'); + const crashed = requireSpawnSync(process.execPath, + ['--input-type=module', '-e', crashScript, migrated.backupPath, targetRoot], + { encoding: 'utf8', windowsHide: true, timeout: 120_000 }); + assert.equal(crashed.status, 91, crashed.stderr || crashed.stdout); + const journals = fs.readdirSync(parent).filter(name => + /^\.dk-restore-transaction-[a-f0-9]{24}\.jsonl$/i.test(name)); + assert.equal(journals.length, 1, 'Interrupted transaction must retain its journal'); + const journalBytes = fs.readFileSync(path.join(parent, journals[0])); + const shortRoot = path.join(aliasParent, 'target'); + assert.throws( + () => restoreLegacyBackup({ + backupPath: migrated.backupPath, targetRoot: shortRoot, + overwrite: true, confirmOffline: true, + }), + /alias|journal|incomplete|witness/i, + 'Same physical target through 8.3 ancestor must not start a second transaction', + ); + assert.deepEqual(fs.readFileSync(path.join(targetRoot, 'original.txt'), 'utf8'), 'KEEP-ORIGINAL'); + assert.deepEqual(fs.readFileSync(path.join(parent, journals[0])), journalBytes); +}); + + +test('W73-28 Unicode uppercase-folded restore aliases fail closed before publication on every OS', async (t) => { + // NTFS case-insensitive name comparison uses an uppercase mapping. A + // conservative preflight must also reject these aliases on POSIX so the + // signed backup cannot behave differently on a Windows restore target. + const longS = '\u017f'; // LATIN SMALL LETTER LONG S; uppercase maps to S. + assert.equal(longS.toUpperCase(), 'S'); + assert.notEqual(longS.toLowerCase(), 's'); + + const source = tempProject(t, 'dk-ntfs-unicode-alias-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const backup = JSON.parse(fs.readFileSync(migrated.backupPath, 'utf8')); + const first = backup.files[0]; + const firstBytes = Buffer.from('FIRST-RESTORED-ENTRY'); + const secondBytes = Buffer.from('SECOND-RESTORED-ENTRY'); + const { createHash } = await import('node:crypto'); + const digest = bytes => 'sha256:' + createHash('sha256').update(bytes).digest('hex'); + const relativeDir = '.development-kit/autopilot/state/'; + backup.files.push({ + ...first, path: relativeDir + 's.json', + bytes: firstBytes.length, fingerprint: digest(firstBytes), + contentBase64: firstBytes.toString('base64'), + }); + backup.files.push({ + ...first, path: relativeDir + longS + '.json', + bytes: secondBytes.length, fingerprint: digest(secondBytes), + contentBase64: secondBytes.toString('base64'), + }); + backup.fileCount = backup.files.length; + const stable = value => Array.isArray(value) ? value.map(stable) + : value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map(key => [key, stable(value[key])])) + : value; + const manifest = backup.files.map(e => ({ path: e.path, fingerprint: e.fingerprint, bytes: e.bytes })); + backup.sourceFingerprint = digest(JSON.stringify(stable(manifest))); + const hostileBackup = path.join(source, 'ntfs-alias-backup.json'); + fs.writeFileSync(hostileBackup, JSON.stringify(backup)); + const targetRoot = tempProject(t, 'dk-ntfs-unicode-alias-target-'); + const original = path.join(targetRoot, 'original.txt'); + fs.writeFileSync(original, 'KEEP-ORIGINAL'); + assert.throws( + () => restoreLegacyBackup({ backupPath: hostileBackup, targetRoot, confirmOffline: true }), + /duplicate|alias|collision|physical identity/i, + 'Two different backup paths naming one physical NTFS file must fail closed before publication', + ); + assert.equal(fs.readFileSync(original, 'utf8'), 'KEEP-ORIGINAL'); + assert.equal(fs.existsSync(path.join(targetRoot, relativeDir, 's.json')), false, + 'Rejected backup must not publish the staged candidate'); +}); + + +test('T04-H68 migration serializes a competing Autopilot writer across cutover', (t) => { + const rootDir = tempProject(t, 'dk-v13-h68-legacy-cutover-'); + const legacy = writeLegacyAutopilot(rootDir, 1); + const backupPath = getLegacyBackupPath(rootDir); + const originalRename = fs.renameSync; + let injected = false; + try { + fs.renameSync = function (from, to) { + const result = originalRename.call(fs, from, to); + if (!injected && path.resolve(to) === path.resolve(backupPath)) { + injected = true; + // A writer racing migration must not acknowledge an unbacked legacy + // revision. The migration owns the gate before inspection; a nested + // competing writer fails closed, then may retry after cutover. + assert.throws( + () => saveStateRevision(workflowState(2, legacy.workflowId), rootDir), + /migration|cutover|gate|orphaned/i, + ); + } + return result; + }; + const migrated = migrateLegacyStateToV2({ + rootDir, completedAt: '2026-10-31T19:00:00.000Z', + }); + assert.equal(migrated.semanticEquivalence, true); + } finally { + fs.renameSync = originalRename; + } + + assert.equal(injected, true); + assert.equal(fs.existsSync(path.join(legacy.stateDir, 'revision-000002.json')), false, + 'a competing writer cannot publish an unbacked legacy revision'); + assert.equal(getCurrentState(rootDir).stateRevision, 1); + assert.equal(loadCanonicalEvents(rootDir).filter((event) => + event.eventType === 'MIGRATION_COMPLETED').length, 1); + + // The rejected writer can retry after cutover and must append to V2. + saveStateRevision(workflowState(2, legacy.workflowId), rootDir); + assert.equal(getCurrentState(rootDir).stateRevision, 2); + assert.equal(loadEntityState('autopilot-workflow', legacy.workflowId, rootDir).stateRevision, 2); + assert.equal(fs.existsSync(path.join(legacy.stateDir, 'revision-000002.json')), false); +}); + +test('T04-H68 orphaned migration gate fails closed without reclaiming a journal or original tree', (t) => { + const rootDir = tempProject(t, 'dk-v13-h68-orphan-'); + const legacy = writeLegacyAutopilot(rootDir, 1); + const gatePath = path.join(rootDir, '.development-kit', '.state-migration.lock'); + const witness = '{"owner":"unknown"}\\n'; + fs.writeFileSync(gatePath, witness, { flag: 'wx' }); + assert.throws(() => migrateLegacyStateToV2({ rootDir }), /migration|gate|orphaned/i); + assert.equal(fs.readFileSync(gatePath, 'utf8'), witness); + assert.equal(getCurrentState(rootDir).stateRevision, 1); + assert.equal(fs.existsSync(getLegacyBackupPath(rootDir)), false); + assert.equal(fs.existsSync(path.join(legacy.stateDir, 'revision-000001.json')), true); +}); + +test('T04-H68 separate process cannot acknowledge legacy state while migration gate is owned', (t) => { + const rootDir = tempProject(t, 'dk-v13-h68-cross-process-'); + const legacy = writeLegacyAutopilot(rootDir, 1); + const state = workflowState(2, legacy.workflowId); + const childScript = [ + 'import { saveStateRevision } from ' + + JSON.stringify(new URL('../runtime/autopilot/state-store.mjs', import.meta.url).href) + ';', + 'const state = JSON.parse(process.argv[2]);', + 'saveStateRevision(state, process.argv[1]);', + ].join('\n'); + withMigrationGate(rootDir, () => { + const child = requireSpawnSync(process.execPath, + ['--input-type=module', '-e', childScript, rootDir, JSON.stringify(state)], + { encoding: 'utf8', timeout: 15_000 }); + assert.notEqual(child.status, 0, 'separate writer must not acknowledge during cutover'); + assert.match(child.stderr, /MigrationGateError|migration.*gate|orphaned/i); + assert.equal(getCurrentState(rootDir).stateRevision, 1); + assert.equal(fs.existsSync(path.join(legacy.stateDir, 'revision-000002.json')), false); + }); + // A separate-process denial must not poison the gate after safe release. + saveStateRevision(state, rootDir); + assert.equal(getCurrentState(rootDir).stateRevision, 2); +}); + +test('T04-H69 explicit legacy recovery cannot rewrite pointer during migration cutover', (t) => { + const rootDir = tempProject(t, 'dk-v13-h69-explicit-recovery-'); + const legacy = writeLegacyAutopilot(rootDir, 1); + const pointerPath = path.join(legacy.stateDir, 'current.json'); + const originalPointer = fs.readFileSync(pointerPath); + const backupPath = getLegacyBackupPath(rootDir); + const originalRename = fs.renameSync; + let injected = false; + try { + fs.renameSync = function (from, to) { + const result = originalRename.call(fs, from, to); + if (!injected && path.resolve(to) === path.resolve(backupPath)) { + injected = true; + assert.throws(() => recoverLatestValidState(rootDir), + /migration|cutover|gate|orphaned/i); + assert.deepEqual(fs.readFileSync(pointerPath), originalPointer, + 'recovery must not modify acknowledged legacy pointer during cutover'); + } + return result; + }; + const migrated = migrateLegacyStateToV2({ rootDir }); + assert.equal(migrated.semanticEquivalence, true); + } finally { + fs.renameSync = originalRename; + } + assert.equal(injected, true); + assert.deepEqual(fs.readFileSync(pointerPath), originalPointer); + assert.equal(getCurrentState(rootDir).stateRevision, 1); +}); + +test('T04-H69 automatic pointer recovery cannot bypass held migration gate', (t) => { + const rootDir = tempProject(t, 'dk-v13-h69-automatic-recovery-'); + const legacy = writeLegacyAutopilot(rootDir, 1); + const pointerPath = path.join(legacy.stateDir, 'current.json'); + const broken = '{"corrupted":true}\\n'; + fs.writeFileSync(pointerPath, broken); + withMigrationGate(rootDir, () => { + assert.throws(() => getCurrentState(rootDir), /migration|cutover|gate|orphaned/i); + assert.equal(fs.readFileSync(pointerPath, 'utf8'), broken, + 'automatic recovery must not rewrite pointer while another migration owns the gate'); + }); + assert.equal(getCurrentState(rootDir).stateRevision, 1, + 'after safe gate release, normal recovery may repair the legacy pointer'); +}); + + +test('T04-H70 transient pointer write failure cannot silently roll legacy recovery back', (t) => { + const rootDir = tempProject(t, 'dk-v13-h70-pointer-write-'); + const legacy = writeLegacyAutopilot(rootDir, 2); + const pointerPath = path.join(legacy.stateDir, 'current.json'); + const broken = '{"corrupted":true}\n'; + fs.writeFileSync(pointerPath, broken); + const originalWrite = fs.writeFileSync; + let injected = 0; + fs.writeFileSync = function (target, content, ...args) { + if (typeof target === 'string' && path.resolve(target) === path.resolve(pointerPath) + && injected++ === 0) { + const error = new Error('injected ENOSPC writing recovery pointer'); + error.code = 'ENOSPC'; + throw error; + } + return originalWrite.call(this, target, content, ...args); + }; + try { + assert.throws(() => recoverLatestValidState(rootDir), /ENOSPC|injected/i, + 'a failed pointer publication must propagate, not retry with an older revision'); + } finally { + fs.writeFileSync = originalWrite; + } + assert.equal(injected, 1); + assert.equal(fs.readFileSync(pointerPath, 'utf8'), broken, + 'a failed pointer repair must leave the original witness unchanged'); + assert.equal(fs.existsSync(path.join(legacy.stateDir, 'revision-000002.json')), true); + assert.equal(getCurrentState(rootDir).stateRevision, 2, + 'after I/O is restored the latest acknowledged revision remains recoverable'); +}); + +test('T04-H70 transient newest revision read failure cannot select older revision', (t) => { + const rootDir = tempProject(t, 'dk-v13-h70-revision-read-'); + const legacy = writeLegacyAutopilot(rootDir, 2); + const pointerPath = path.join(legacy.stateDir, 'current.json'); + const broken = '{"corrupted":true}\n'; + fs.writeFileSync(pointerPath, broken); + const newest = path.join(legacy.stateDir, 'revision-000002.json'); + const originalRead = fs.readFileSync; + let injected = 0; + fs.readFileSync = function (target, ...args) { + if (typeof target === 'string' && path.resolve(target) === path.resolve(newest) + && injected++ === 0) { + const error = new Error('injected EIO reading newest acknowledged revision'); + error.code = 'EIO'; + throw error; + } + return originalRead.call(this, target, ...args); + }; + try { + assert.throws(() => recoverLatestValidState(rootDir), /EIO|injected/i, + 'transient I/O failure must not be mistaken for an invalid revision'); + } finally { + fs.readFileSync = originalRead; + } + assert.equal(injected, 1); + assert.equal(fs.readFileSync(pointerPath, 'utf8'), broken, + 'a failed read must not publish an older pointer'); + assert.equal(getCurrentState(rootDir).stateRevision, 2); +}); + + +// H71 negative controls: a transient I/O failure is not malformed JSON. +test('T04-H71 legacy current pointer EIO fails closed without rewriting acknowledged pointer', (t) => { + const rootDir = tempProject(t, 'dk-v13-h71-pointer-eio-'); + const legacy = writeLegacyAutopilot(rootDir, 2); + const pointerPath = path.join(legacy.stateDir, 'current.json'); + const originalPointer = fs.readFileSync(pointerPath); + const originalRead = fs.readFileSync; + let injected = 0; + fs.readFileSync = function (target, ...args) { + if (typeof target === 'string' && path.resolve(target) === path.resolve(pointerPath) + && injected++ === 0) { + const error = new Error('injected EIO reading acknowledged legacy pointer'); + error.code = 'EIO'; + throw error; + } + return originalRead.call(this, target, ...args); + }; + try { + assert.throws(() => getCurrentState(rootDir), /EIO|injected/i, + 'pointer read I/O error is not evidence of corrupt JSON and must not trigger repair'); + } finally { + fs.readFileSync = originalRead; + } + assert.equal(injected, 1); + assert.deepEqual(fs.readFileSync(pointerPath), originalPointer, + 'an uncertain pointer read must preserve the original acknowledged pointer'); + assert.equal(getCurrentState(rootDir).stateRevision, 2); +}); + +test('T04-H71 legacy current revision EIO fails closed without rewriting pointer', (t) => { + const rootDir = tempProject(t, 'dk-v13-h71-revision-eio-'); + const legacy = writeLegacyAutopilot(rootDir, 2); + const pointerPath = path.join(legacy.stateDir, 'current.json'); + const latestPath = path.join(legacy.stateDir, 'revision-000002.json'); + const originalPointer = fs.readFileSync(pointerPath); + const originalRead = fs.readFileSync; + let injected = 0; + fs.readFileSync = function (target, ...args) { + if (typeof target === 'string' && path.resolve(target) === path.resolve(latestPath) + && injected++ === 0) { + const error = new Error('injected EIO reading current acknowledged revision'); + error.code = 'EIO'; + throw error; + } + return originalRead.call(this, target, ...args); + }; + try { + assert.throws(() => getCurrentState(rootDir), /EIO|injected/i, + 'revision read I/O error must propagate rather than invoking pointer repair'); + } finally { + fs.readFileSync = originalRead; + } + assert.equal(injected, 1); + assert.deepEqual(fs.readFileSync(pointerPath), originalPointer); + assert.equal(getCurrentState(rootDir).stateRevision, 2); +}); + + +test('T04-H72 symlinked legacy Autopilot revision cannot be omitted from backup or certified', (t) => { + const rootDir = tempProject(t, 'dk-v13-h72-revision-link-'); + const legacy = writeLegacyAutopilot(rootDir, 1); + const revision = path.join(legacy.stateDir, 'revision-000001.json'); + const external = path.join(rootDir, 'external-revision.json'); + fs.renameSync(revision, external); + try { + fs.symlinkSync(external, revision, 'file'); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { + t.skip('Host does not permit file symlink creation'); + return; + } + throw error; + } + const before = fs.readFileSync(external); + assert.throws(() => inspectLegacyState(rootDir), /symbolic link|symlink/i, + 'inspection must not certify a backup that omits an imported revision'); + assert.throws(() => migrateLegacyStateToV2({ rootDir }), /symbolic link|symlink/i); + assert.deepEqual(fs.readFileSync(external), before, 'original revision must remain unchanged'); + assert.equal(fs.readlinkSync(revision), external, 'original link must remain intact'); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'state')), false, + 'rejected migration must not publish a V2 ledger or partial backup'); +}); + +test('T04-H72 symlinked legacy Autopilot pointer cannot be omitted from backup', (t) => { + const rootDir = tempProject(t, 'dk-v13-h72-pointer-link-'); + const legacy = writeLegacyAutopilot(rootDir, 1); + const pointer = path.join(legacy.stateDir, 'current.json'); + const external = path.join(rootDir, 'external-current.json'); + fs.renameSync(pointer, external); + try { + fs.symlinkSync(external, pointer, 'file'); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { + t.skip('Host does not permit file symlink creation'); + return; + } + throw error; + } + assert.throws(() => inspectLegacyState(rootDir), /symbolic link|symlink/i); + assert.throws(() => migrateLegacyStateToV2({ rootDir }), /symbolic link|symlink/i); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'state')), false); +}); + + +test('T04-H72 junctioned legacy state root cannot publish a fingerprint for external inputs', (t) => { + const rootDir = tempProject(t, 'dk-v13-h72-state-junction-'); + const legacy = writeLegacyAutopilot(rootDir, 1); + const external = path.join(rootDir, 'external-legacy-state'); + fs.renameSync(legacy.stateDir, external); + try { + fs.symlinkSync(external, legacy.stateDir, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { + t.skip('Host does not permit directory junction/symlink creation'); + return; + } + throw error; + } + assert.throws(() => inspectLegacyState(rootDir), /symbolic link|symlink/i); + assert.throws(() => migrateLegacyStateToV2({ rootDir }), /symbolic link|symlink/i); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'state')), false); + assert.equal(fs.existsSync(path.join(external, 'revision-000001.json')), true, + 'rejected migration must retain original revision bytes'); +}); + + +test('T04-H73 orphaned Autopilot pointer cannot be silently certified as empty legacy history', (t) => { + const rootDir = tempProject(t, 'dk-v13-h73-orphan-autopilot-'); + const legacy = writeLegacyAutopilot(rootDir, 1); + const pointer = path.join(legacy.stateDir, 'current.json'); + const original = fs.readFileSync(pointer); + fs.unlinkSync(path.join(legacy.stateDir, 'revision-000001.json')); + + assert.throws(() => inspectLegacyState(rootDir), + /Autopilot|revision|orphan|incomplete|missing/i, + 'a surviving current pointer without its revision is incomplete legacy input, not an empty history'); + assert.throws(() => migrateLegacyStateToV2({ rootDir }), + /Autopilot|revision|orphan|incomplete|missing/i); + assert.deepEqual(fs.readFileSync(pointer), original, 'the original pointer witness must remain intact'); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'state')), false, + 'an orphaned legacy pointer must not publish a canonical migration-completed event'); +}); + +test('T04-H73 orphaned orchestration revisions without immutable manifest cannot be certified', (t) => { + const rootDir = tempProject(t, 'dk-v13-h73-orphan-run-'); + const legacy = writeLegacyRun(rootDir); + const revision = path.join(legacy.runDir, 'state-revisions', '00000003.json'); + const before = fs.readFileSync(revision); + fs.unlinkSync(path.join(legacy.runDir, 'manifest.json')); + + assert.throws(() => inspectLegacyState(rootDir), + /orchestration|manifest|orphan|incomplete|missing/i, + 'a surviving revision/pointer without the immutable manifest is an incomplete run'); + assert.throws(() => migrateLegacyStateToV2({ rootDir }), + /orchestration|manifest|orphan|incomplete|missing/i); + assert.deepEqual(fs.readFileSync(revision), before, 'surviving original revisions must remain intact'); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'state')), false, + 'orphaned orchestration history must not be recorded as migrated'); +}); + + +test('T04-H74 orphaned orchestration current pointer with no revision files cannot certify manifest-only migration', (t) => { + const rootDir = tempProject(t, 'dk-v13-h74-orphan-run-pointer-'); + const legacy = writeLegacyRun(rootDir); + const pointer = path.join(legacy.runDir, 'current-state.json'); + const pointerBytes = fs.readFileSync(pointer); + const manifest = path.join(legacy.runDir, 'manifest.json'); + const manifestBytes = fs.readFileSync(manifest); + const revisionDir = path.join(legacy.runDir, 'state-revisions'); + for (const name of fs.readdirSync(revisionDir)) fs.unlinkSync(path.join(revisionDir, name)); + // Remove the final-state cross-check so the only surviving revision witness + // is the acknowledged current-state pointer. A manifest alone is revision 1, + // not proof that the later current revision can be silently discarded. + fs.unlinkSync(path.join(legacy.runDir, 'final-state.json')); + + assert.throws(() => inspectLegacyState(rootDir), + /orchestration|pointer|revision|orphan|incomplete|missing/i, + 'surviving pointer to absent revisions must not be certified as manifest-only history'); + assert.throws(() => migrateLegacyStateToV2({ rootDir }), + /orchestration|pointer|revision|orphan|incomplete|missing/i); + assert.deepEqual(fs.readFileSync(pointer), pointerBytes, + 'rejected migration must preserve original current pointer'); + assert.deepEqual(fs.readFileSync(manifest), manifestBytes, + 'rejected migration must preserve immutable manifest'); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'state')), false, + 'rejected migration must not publish canonical layout or completion event'); +}); + + +test('T04-H78 signed backup rejects NTFS alternate streams and reserved device names before staging on every host', async (t) => { + const source = tempProject(t, 'dk-v13-h78-unsafe-winpath-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const original = JSON.parse(fs.readFileSync(migrated.backupPath, 'utf8')); + const { createHash } = await import('node:crypto'); + const canonical = (value) => Array.isArray(value) ? value.map(canonical) + : value && typeof value === 'object' + ? Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])])) + : value; + const paths = [ + '.development-kit/autopilot/state/revision-000001.json:evil', + '.development-kit/autopilot/state/CON', + '.development-kit/autopilot/state/AUX.txt', + '.development-kit/autopilot/state/COM¹.txt', + '.development-kit/autopilot/state/bad?.json', + ]; + for (const [i, unsafePath] of paths.entries()) { + const bundle = structuredClone(original); + bundle.files[0].path = unsafePath; + const manifest = bundle.files.map(({ path: entryPath, fingerprint, bytes }) => + ({ path: entryPath, fingerprint, bytes })); + bundle.sourceFingerprint = 'sha256:' + + createHash('sha256').update(JSON.stringify(canonical(manifest))).digest('hex'); + const backup = path.join(source, 'h78-unsafe-' + i + '.json'); + const targetRoot = path.join(source, 'h78-target-' + i); + fs.writeFileSync(backup, JSON.stringify(bundle)); + assert.throws( + () => restoreLegacyBackup({ backupPath: backup, targetRoot, confirmOffline: true }), + /unsafe Windows restore path component/i, + 'portable restore must reject ' + unsafePath + ' before stage creation'); + assert.equal(fs.existsSync(targetRoot), false, + 'rejected Windows-unsafe name must not publish a target tree'); + assert.equal(fs.readdirSync(source).some((name) => name.startsWith('.dk-legacy-restore-stage-')), false, + 'rejected Windows-unsafe name must not create a staging tree'); + } +}); + + +test('T04-H80 interrupted promotion must not acknowledge a missing restored descendant', async (t) => { + const source = tempProject(t, 'dk-v13-h80-source-'); + writeLegacyAutopilot(source, 2); + const migrated = migrateLegacyStateToV2({ rootDir: source }); + const targetRoot = tempProject(t, 'dk-v13-h80-target-'); + fs.writeFileSync(path.join(targetRoot, 'original-sentinel.txt'), 'ORIGINAL'); + const originalWrite = fs.writeFileSync; + let injected = false; + fs.writeFileSync = function injectPostPublicationCrash(fd, content, ...args) { + const result = originalWrite.call(this, fd, content, ...args); + if (!injected && typeof fd === 'number' && + String(content).includes('"phase":"CANDIDATE_PUBLISHED"')) { + injected = true; + throw new Error('H80 injected interruption before promotion acknowledgement'); + } + return result; + }; + try { + assert.throws( + () => restoreLegacyBackup({ backupPath: migrated.backupPath, targetRoot, confirmOffline: true }), + /H80 injected interruption/); + } finally { + fs.writeFileSync = originalWrite; + } + assert.equal(injected, true, 'fault injection must reach the post-promotion boundary'); + const restored = path.join(targetRoot, '.development-kit', 'autopilot', 'state', 'revision-000001.json'); + assert.equal(fs.existsSync(restored), true, 'candidate must have been published before injection'); + fs.unlinkSync(restored); // Model loss of a staged descendant after interrupted namespace publication. + + const { createHash } = await import('node:crypto'); + const journal = path.join(path.dirname(targetRoot), '.dk-restore-transaction-' + + createHash('sha256').update(path.resolve(targetRoot)).digest('hex').slice(0, 24) + '.jsonl'); + const journalBefore = fs.readFileSync(journal); + const prepared = JSON.parse(journalBefore.toString('utf8').split('\n')[0]); + const displaced = path.join(path.dirname(targetRoot), prepared.stageName + '-original'); + assert.equal(fs.readFileSync(path.join(displaced, 'original-sentinel.txt'), 'utf8'), 'ORIGINAL'); + assert.throws( + () => recoverLegacyRestore({ targetRoot, confirmOffline: true }), + /candidate|fingerprint|content|integrity/i, + 'recovery must not acknowledge a candidate whose restored files are missing'); + assert.deepEqual(fs.readFileSync(journal), journalBefore, + 'rejected recovery must retain the exact hash-chained journal without an acknowledgement'); + assert.equal(fs.existsSync(targetRoot), true, 'rejected candidate remains for offline examination'); + assert.equal(fs.readFileSync(path.join(displaced, 'original-sentinel.txt'), 'utf8'), 'ORIGINAL', + 'displaced original must remain intact after rejected recovery'); +}); diff --git a/scripts/state-engine-v2.test.mjs b/scripts/state-engine-v2.test.mjs new file mode 100644 index 000000000..c266d928b --- /dev/null +++ b/scripts/state-engine-v2.test.mjs @@ -0,0 +1,747 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import crypto from 'node:crypto'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; + +import { + appendEntityState, + canonicalStateJson, + cleanupOrphanedStateTemps, + countStateEngineFiles, + getStateEnginePaths, + ensureStateEngineLayout, + loadCanonicalEvents, + loadStateIndex, + loadStateSnapshot, + queryStateIndex, + rebuildSnapshotFromEvents, + rebuildStateIndex, + rebuildStateSnapshot, + verifyStateEngineIntegrity, +} from '../runtime/orchestration/state-engine-v2.mjs'; +import { + getCurrentState, + saveStateRevision, +} from '../runtime/autopilot/state-store.mjs'; +import { + loadCurrentRunState, + persistRunManifest, + persistRunStateRevision, +} from '../runtime/orchestration/orchestration-run.mjs'; + +function tempProject(t, prefix = 'dk-state-v2-') { + const rootDir = fs.mkdtempSync(path.join(os.tmpdir(), prefix)); + t.after(() => fs.rmSync(rootDir, { recursive: true, force: true })); + return rootDir; +} + +function workflowState(revision = 1, overrides = {}) { + return { + schemaVersion: '1.0.0', + workflowId: 'wf-state-v2', + projectId: 'proj-state-v2', + workspaceId: 'ws-state-v2', + workflowMode: 'autopilot', + autonomyLevel: 'guided-autopilot', + workflowStatus: 'executing', + currentStage: revision > 1 ? 'DEFINE' : 'UNDERSTAND', + completedStages: revision > 1 ? ['UNDERSTAND'] : [], + skippedStages: [], + blockedStages: [], + activeAction: null, + pendingApproval: null, + pendingConfirmation: null, + activeDecisionMenu: null, + stateRevision: revision, + createdAt: '2026-10-07T18:00:00.000Z', + updatedAt: new Date(Date.parse('2026-10-07T18:00:00.000Z') + revision * 1000).toISOString(), + frameworkVersion: '0.11.2', + ...overrides, + }; +} + +function runState(revision = 1, overrides = {}) { + return { + schemaVersion: '1.0.0', + contractId: 'INC-STATE-V2', + taskId: 'TASK-STATE-V2', + runId: 'run-state-v2', + sourceFingerprint: 'sha256:' + 'a'.repeat(64), + createdAt: '2026-10-07T18:10:00.000Z', + updatedAt: new Date(Date.parse('2026-10-07T18:10:00.000Z') + revision * 1000).toISOString(), + stateRevision: revision, + state: revision === 1 ? 'READY' : 'IMPLEMENTING', + executionStrategy: 'sequential-fresh-context', + hostCapabilities: { + fileRead: true, + fileWrite: true, + shell: true, + git: true, + freshContext: true, + subagents: false, + parallelAgents: false, + browser: false, + visualInspection: false, + externalModelRouting: false, + }, + manualEvidenceRequired: false, + requiredGates: { + verification: ['specification', 'tests'], + reviewers: ['code-reviewer'], + controlDomains: [], + humanApprovals: [], + configurationReadiness: { required: false }, + }, + completedGates: [], + correctionAttempt: 0, + failureSignatures: [], + verificationVerdict: null, + acceptanceState: 'PENDING', + ...overrides, + }; +} + +test('AC-012 canonical event history is monotonic, hash-chained, and reconstructable', (t) => { + const rootDir = tempProject(t); + const first = workflowState(1); + const second = workflowState(2); + + appendEntityState({ + rootDir, + entityType: 'autopilot-workflow', + entityId: first.workflowId, + state: first, + actorClass: 'test', + timestamp: first.updatedAt, + }); + appendEntityState({ + rootDir, + entityType: 'autopilot-workflow', + entityId: second.workflowId, + state: second, + actorClass: 'test', + timestamp: second.updatedAt, + }); + + const events = loadCanonicalEvents(rootDir); + assert.equal(events.length, 2); + assert.equal(events[0].sequence, 1); + assert.equal(events[1].sequence, 2); + assert.equal(events[0].previousEventHash, null); + assert.equal(events[1].previousEventHash, events[0].eventHash); + + const rebuilt = rebuildSnapshotFromEvents(events); + assert.deepEqual( + rebuilt.entities['autopilot-workflow'][first.workflowId].state, + second, + ); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('AC-012 canonical history detects mutation and reordering', (t) => { + const rootDir = tempProject(t); + appendEntityState({ + rootDir, + entityType: 'generic-state', + entityId: 'one', + state: { stateRevision: 1, value: 1 }, + actorClass: 'test', + timestamp: '2026-10-07T18:20:00.000Z', + }); + appendEntityState({ + rootDir, + entityType: 'generic-state', + entityId: 'one', + state: { stateRevision: 2, value: 2 }, + actorClass: 'test', + timestamp: '2026-10-07T18:20:01.000Z', + }); + + const paths = getStateEnginePaths(rootDir); + const lines = fs.readFileSync(paths.events, 'utf8').trim().split(/\r?\n/); + const mutated = JSON.parse(lines[0]); + mutated.payload.state.value = 99; + fs.writeFileSync(paths.events, JSON.stringify(mutated) + '\n' + lines[1] + '\n', 'utf8'); + assert.throws(() => loadCanonicalEvents(rootDir), /hash mismatch/i); + + fs.writeFileSync(paths.events, lines[1] + '\n' + lines[0] + '\n', 'utf8'); + assert.throws(() => loadCanonicalEvents(rootDir), /First state event sequence|sequence/i); +}); + +test('AC-012 canonical tail truncation fails closed when the materialized snapshot witnesses later history', (t) => { + const rootDir = tempProject(t); + appendEntityState({ + rootDir, + entityType: 'generic-state', + entityId: 'tail-witness', + state: { stateRevision: 1, value: 1 }, + actorClass: 'test', + timestamp: '2026-10-07T18:21:00.000Z', + }); + appendEntityState({ + rootDir, + entityType: 'generic-state', + entityId: 'tail-witness', + state: { stateRevision: 2, value: 2 }, + actorClass: 'test', + timestamp: '2026-10-07T18:21:01.000Z', + }); + + const paths = getStateEnginePaths(rootDir); + const lines = fs.readFileSync(paths.events, 'utf8').trim().split(/\r?\n/); + assert.equal(lines.length, 2); + + // Simulate loss/removal of the canonical tail while retaining the previously + // materialized snapshot as an integrity witness. + fs.writeFileSync(paths.events, lines[0] + '\n', 'utf8'); + + assert.throws( + () => loadStateSnapshot(rootDir, { rebuildIfNeeded: true }), + /history appears truncated/i, + ); +}); + +test('State Engine rejects a state-root symlink or junction that resolves outside the project', (t) => { + const rootDir = tempProject(t, 'dk-state-realpath-'); + const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'dk-state-outside-')); + t.after(() => fs.rmSync(outside, { recursive: true, force: true })); + + const dkDir = path.join(rootDir, '.development-kit'); + fs.mkdirSync(dkDir, { recursive: true }); + const statePath = path.join(dkDir, 'state'); + + try { + fs.symlinkSync(outside, statePath, process.platform === 'win32' ? 'junction' : 'dir'); + } catch (error) { + if (['EPERM', 'EACCES', 'ENOTSUP'].includes(error.code)) { + t.skip('Host does not permit directory symlink/junction creation'); + return; + } + throw error; + } + + assert.throws( + () => appendEntityState({ + rootDir, + entityType: 'generic-state', + entityId: 'escape-attempt', + state: { stateRevision: 1, value: true }, + actorClass: 'test', + timestamp: '2026-10-07T18:22:00.000Z', + }), + /resolves outside project root/, + ); + + assert.equal(fs.readdirSync(outside).length, 0); +}); + +test('tampered State Engine schema contract fails closed on read', (t) => { + const rootDir = tempProject(t); + const state = workflowState(1); + saveStateRevision(state, rootDir); + const paths = getStateEnginePaths(rootDir); + + const schema = JSON.parse(fs.readFileSync(paths.schema, 'utf8')); + schema.authority.canonicalHistory = 'something-else.jsonl'; + fs.writeFileSync(paths.schema, JSON.stringify(schema, null, 2) + '\n', 'utf8'); + + assert.throws( + () => getCurrentState(rootDir), + /schema\.json does not match the runtime schema contract/, + ); +}); + +test('AC-013 materialized snapshot deletion/corruption rebuilds from canonical history', (t) => { + const rootDir = tempProject(t); + const state = workflowState(1); + appendEntityState({ + rootDir, + entityType: 'autopilot-workflow', + entityId: state.workflowId, + state, + actorClass: 'test', + timestamp: state.updatedAt, + }); + + const paths = getStateEnginePaths(rootDir); + const expected = loadStateSnapshot(rootDir); + fs.rmSync(paths.snapshot); + const rebuiltAfterDelete = loadStateSnapshot(rootDir, { rebuildIfNeeded: true }); + assert.equal(canonicalStateJson(rebuiltAfterDelete), canonicalStateJson(expected)); + + fs.writeFileSync(paths.snapshot, '{"corrupt":true}\n', 'utf8'); + const rebuiltAfterCorruption = rebuildStateSnapshot(rootDir); + assert.equal(canonicalStateJson(rebuiltAfterCorruption), canonicalStateJson(expected)); +}); + +test('AC-014 disposable index deletion/rebuild preserves semantically equivalent query state', (t) => { + const rootDir = tempProject(t); + const run = runState(1); + appendEntityState({ + rootDir, + entityType: 'orchestration-run', + entityId: run.contractId + '/' + run.runId, + state: run, + refs: { contractId: run.contractId, taskId: run.taskId, runId: run.runId }, + actorClass: 'test', + timestamp: run.updatedAt, + }); + + const before = loadStateIndex(rootDir); + const queryBefore = queryStateIndex({ rootDir, contractId: run.contractId }); + const paths = getStateEnginePaths(rootDir); + fs.rmSync(paths.index); + const rebuilt = rebuildStateIndex(rootDir); + const queryAfter = queryStateIndex({ rootDir, contractId: run.contractId }); + + assert.equal(canonicalStateJson(rebuilt), canonicalStateJson(before)); + assert.deepEqual(queryAfter, queryBefore); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('interrupted temporary state files cannot advance canonical history', (t) => { + const rootDir = tempProject(t); + const state = workflowState(1); + appendEntityState({ + rootDir, + entityType: 'autopilot-workflow', + entityId: state.workflowId, + state, + actorClass: 'test', + timestamp: state.updatedAt, + }); + const paths = getStateEnginePaths(rootDir); + const before = fs.readFileSync(paths.events, 'utf8'); + + fs.writeFileSync( + path.join(paths.stateRoot, 'events.jsonl.tmp-interrupted'), + before + '{"partial":', + 'utf8', + ); + + assert.equal(loadCanonicalEvents(rootDir).length, 1); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); + const removed = cleanupOrphanedStateTemps(rootDir); + assert.equal(removed.length, 1); + assert.equal(fs.readFileSync(paths.events, 'utf8'), before); +}); + +test('T04 concurrent temporary cleanup preserves an active writer staging file', (t) => { + const rootDir = tempProject(t); + ensureStateEngineLayout(rootDir); + const paths = getStateEnginePaths(rootDir); + const liveStage = path.join(paths.stateRoot, 'events.jsonl.tmp-' + process.pid + '-00000000-0000-4000-8000-000000000001'); + const staleStage = path.join(paths.stateRoot, 'events.jsonl.tmp-interrupted'); + fs.writeFileSync(liveStage, 'active writer staging data'); + fs.writeFileSync(staleStage, 'abandoned data'); + + const removed = cleanupOrphanedStateTemps(rootDir); + assert.equal(fs.readFileSync(liveStage, 'utf8'), 'active writer staging data', + 'cleanup must not unlink the temporary file of a still-running writer'); + assert.ok(!removed.includes(liveStage)); + assert.ok(removed.includes(staleStage)); + assert.equal(fs.existsSync(staleStage), false); +}); + +test('fresh Autopilot projects use State Engine V2 without legacy revision micro-files', (t) => { + const rootDir = tempProject(t); + const first = workflowState(1); + saveStateRevision(first, rootDir); + const second = workflowState(2); + saveStateRevision(second, rootDir); + + assert.deepEqual(getCurrentState(rootDir), second); + assert.equal(fs.existsSync(path.join(rootDir, '.development-kit', 'autopilot', 'state')), false); + assert.equal(loadCanonicalEvents(rootDir).length, 2); + assert.equal(countStateEngineFiles(rootDir), 4); +}); + +test('fresh orchestration transition state uses V2 while manifest remains an immutable artifact', (t) => { + const rootDir = tempProject(t); + const run = runState(1); + persistRunManifest(run, rootDir); + const first = persistRunStateRevision(run, rootDir); + assert.equal(first.stateEngine, 'v2'); + + const second = runState(2); + const next = persistRunStateRevision(second, rootDir); + assert.equal(next.stateEngine, 'v2'); + assert.deepEqual(loadCurrentRunState(run.contractId, run.runId, rootDir), second); + + const runDir = path.join(rootDir, '.development-kit', 'runs', run.contractId, run.runId); + assert.equal(fs.existsSync(path.join(runDir, 'state-revisions')), false); + assert.equal(fs.existsSync(path.join(runDir, 'current-state.json')), false); + assert.equal(fs.existsSync(path.join(runDir, 'manifest.json')), true); +}); + +test('orchestration V2 refuses same-revision semantic overwrite', (t) => { + const rootDir = tempProject(t); + const run = runState(1); + persistRunManifest(run, rootDir); + persistRunStateRevision(run, rootDir); + + const forged = structuredClone(run); + forged.state = 'CORRECTING'; + assert.throws( + () => persistRunStateRevision(forged, rootDir), + /Refusing to overwrite orchestration run state revision/, + ); +}); + +test('T04-P1 patch replay preserves own __proto__ data without prototype pollution', (t) => { + const rootDir = tempProject(t, 'dk-t04-patch-proto-'); + const key = '__t04PatchPollutionProbe'; + assert.equal(Object.hasOwn(Object.prototype, key), false, 'probe must start clean'); + try { + appendEntityState({ + rootDir, entityType: 'generic-state', entityId: 'prototype-safety', + state: { stateRevision: 1, metadata: {} }, actorClass: 'security-regression', + }); + const changed = JSON.parse('{"stateRevision":2,"metadata":{"__proto__":{"__t04PatchPollutionProbe":true},"safe":42}}'); + appendEntityState({ + rootDir, entityType: 'generic-state', entityId: 'prototype-safety', + state: changed, actorClass: 'security-regression', + }); + assert.equal(Object.hasOwn(Object.prototype, key), false, + 'patch traversal must never mutate Object.prototype'); + const snapshot = loadStateSnapshot(rootDir, { rebuildIfNeeded: true }); + const actual = snapshot.entities['generic-state']['prototype-safety'].state; + assert.equal(Object.hasOwn(actual.metadata, '__proto__'), true, + 'literal JSON __proto__ must be retained as an own data property'); + assert.deepEqual(actual.metadata, changed.metadata); + const replay = rebuildSnapshotFromEvents(loadCanonicalEvents(rootDir)); + assert.deepEqual(replay.entities['generic-state']['prototype-safety'].state, changed); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); + } finally { + delete Object.prototype[key]; + } +}); + + +test('V13-SEC-ENTITY-01 prototype-sensitive entity identities and index references are literal own keys', (t) => { + const rootDir = tempProject(t, 'dk-v13-identity-'); + const moduleUrl = new URL('../runtime/orchestration/state-engine-v2.mjs', import.meta.url).href; + const childCode = [ + 'import assert from "node:assert/strict";', + 'import { appendEntityState, loadStateSnapshot, loadEntityState, queryStateIndex, verifyStateEngineIntegrity } from ' + JSON.stringify(moduleUrl) + ';', + 'const rootDir = process.argv[1];', + 'for (const [type,id] of [["generic-state","__proto__"],["constructor","entity"],["generic-state","constructor"]]) {', + ' appendEntityState({ rootDir, entityType:type, entityId:id, state:{stateRevision:1,value:type+"/"+id}, refs:{contractId:"__proto__",taskId:"constructor"}, actorClass:"identity-security-test" });', + ' const state = loadEntityState(type,id,rootDir);', + ' assert.equal(state?.value, type+"/"+id);', + '}', + 'const snapshot = loadStateSnapshot(rootDir);', + 'assert.ok(Object.hasOwn(snapshot.entities, "constructor"));', + 'assert.ok(Object.hasOwn(snapshot.entities["generic-state"], "__proto__"));', + 'assert.ok(Object.hasOwn(snapshot.entities["generic-state"], "constructor"));', + 'assert.equal(queryStateIndex({rootDir,contractId:"__proto__"}).length, 3);', + 'assert.equal(queryStateIndex({rootDir,taskId:"constructor"}).length, 3);', + 'assert.equal(verifyStateEngineIntegrity(rootDir).valid, true);', + 'assert.equal(Object.hasOwn(Object.prototype, "state"), false);', + 'assert.equal(Object.hasOwn(Object.prototype, "stateRevision"), false);', + ].join("\n"); + const child = spawnSync(process.execPath, ['--input-type=module', '-e', childCode, rootDir], + { encoding: 'utf8', timeout: 20000 }); + assert.equal(child.status, 0, 'Identity replay must not pollute inherited objects or lose records: ' + child.stderr); +}); + +test('V13-CONC-SCHEMA-01 another initializer never observes an incomplete published schema', (t) => { + const rootDir = tempProject(t, 'dk-v13-schema-'); + const moduleUrl = new URL('../runtime/orchestration/state-engine-v2.mjs', import.meta.url).href; + const originalWrite = fs.writeFileSync; + let interleaved = false; + fs.writeFileSync = function interleaveSchemaWrite(fd, content, ...args) { + if (!interleaved && typeof fd === 'number' && typeof content === 'string' + && content.includes('canonicalHistory') && content.includes('materializedSnapshot')) { + interleaved = true; + const code = 'import {ensureStateEngineLayout} from ' + JSON.stringify(moduleUrl) + + '; ensureStateEngineLayout(process.argv[1]);'; + const child = spawnSync(process.execPath, ['--input-type=module', '-e', code, rootDir], + { encoding: 'utf8', timeout: 20000 }); + assert.equal(child.status, 0, 'Concurrent initializer must never read an empty schema: ' + child.stderr); + } + return originalWrite.call(this, fd, content, ...args); + }; + try { + ensureStateEngineLayout(rootDir); + } finally { + fs.writeFileSync = originalWrite; + } + assert.equal(interleaved, true, 'Fixture must interleave at schema publication'); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('V13-INDEX-IDENTITY-01 composite entity keys cannot collide across colon-bearing identities', (t) => { + const rootDir = tempProject(t, 'dk-v13-index-identity-'); + const cases = [ + { entityType: 'a::b', entityId: 'c', contractId: 'contract-first', + taskId: 'task-first', runId: 'run-first', lifecycleId: 'lifecycle-first' }, + { entityType: 'a', entityId: 'b::c', contractId: 'contract-second', + taskId: 'task-second', runId: 'run-second', lifecycleId: 'lifecycle-second' }, + ]; + for (const entry of cases) { + appendEntityState({ + rootDir, + entityType: entry.entityType, + entityId: entry.entityId, + state: { stateRevision: 1, marker: entry.contractId }, + refs: { contractId: entry.contractId, taskId: entry.taskId, + runId: entry.runId, lifecycleId: entry.lifecycleId }, + actorClass: 'index-identity-regression', + }); + } + const index = loadStateIndex(rootDir); + assert.equal(index.records.length, 2); + assert.equal(new Set(index.records.map((record) => record.key)).size, 2, + 'Distinct valid entity tuples must never share the same index key'); + for (const entry of cases) { + for (const [filter, value] of [ + ['contractId', entry.contractId], ['taskId', entry.taskId], + ['runId', entry.runId], ['lifecycleId', entry.lifecycleId], + ]) { + const found = queryStateIndex({ rootDir, [filter]: value }); + assert.equal(found.length, 1, filter + ' must not return another entity with a colliding key'); + assert.equal(found[0].entityType, entry.entityType); + assert.equal(found[0].entityId, entry.entityId); + } + } + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + + +test('V13-INDEX-IDENTITY-02 stale delimiter-based index is rebuilt before serving filtered queries', (t) => { + const rootDir = tempProject(t, 'dk-v13-index-upgrade-'); + for (const entry of [ + { entityType: 'a::b', entityId: 'c', contractId: 'first' }, + { entityType: 'a', entityId: 'b::c', contractId: 'second' }, + ]) { + appendEntityState({ + rootDir, entityType: entry.entityType, entityId: entry.entityId, + state: { stateRevision: 1, marker: entry.contractId }, + refs: { contractId: entry.contractId }, + actorClass: 'index-upgrade-regression', + }); + } + const paths = getStateEnginePaths(rootDir); + const oldIndex = structuredClone(loadStateIndex(rootDir)); + oldIndex.records = oldIndex.records.map((record) => ({ + ...record, key: record.entityType + '::' + record.entityId, + })); + for (const map of [oldIndex.byContract, oldIndex.byTask, oldIndex.byRun, oldIndex.byLifecycle]) { + for (const [ref, keys] of Object.entries(map)) { + map[ref] = keys.map((key) => { + const record = loadStateIndex(rootDir).records.find((entry) => entry.key === key); + return record.entityType + '::' + record.entityId; + }); + } + } + fs.writeFileSync(paths.index, JSON.stringify(oldIndex) + '\n'); + const first = queryStateIndex({ rootDir, contractId: 'first' }); + assert.equal(first.length, 1, 'An old derived index must not leak another contract'); + assert.equal(first[0].entityType, 'a::b'); + assert.equal(first[0].entityId, 'c'); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('V13-CONC-BOOTSTRAP-02 interrupted initial hard-link publication is safely recoverable', (t) => { + const rootDir = tempProject(t, 'dk-v13-bootstrap-link-'); + const moduleUrl = new URL('../runtime/orchestration/state-engine-v2.mjs', import.meta.url).href; + const childCode = [ + 'import fs from "node:fs";', + 'import { ensureStateEngineLayout } from ' + JSON.stringify(moduleUrl) + ';', + 'const link = fs.linkSync;', + 'fs.linkSync = (...args) => { link(...args); process.exit(77); };', + 'ensureStateEngineLayout(process.argv[1]);', + 'process.exit(99);', + ].join('\n'); + const child = spawnSync(process.execPath, ['--input-type=module', '-e', childCode, rootDir], + { encoding: 'utf8', timeout: 20000 }); + assert.equal(child.status, 77, 'Fixture must terminate after publication but before staging unlink: ' + child.stderr); + const stateRoot = path.join(rootDir, '.development-kit', 'state'); + const tempNames = fs.readdirSync(stateRoot).filter((name) => name.startsWith('schema.json.tmp-')); + assert.equal(tempNames.length, 1, 'Interrupted bootstrap staging link must remain'); + assert.equal(fs.statSync(path.join(stateRoot, 'schema.json')).nlink, 2); + ensureStateEngineLayout(rootDir); + assert.equal(fs.statSync(path.join(stateRoot, 'schema.json')).nlink, 1); + assert.equal(fs.existsSync(path.join(stateRoot, tempNames[0])), false); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('V13-CONC-BOOTSTRAP-03 unrelated hard links remain fail-closed and preserved', (t) => { + const rootDir = tempProject(t, 'dk-v13-bootstrap-untrusted-'); + const paths = ensureStateEngineLayout(rootDir); + const external = path.join(rootDir, 'unrelated-schema-link'); + fs.linkSync(paths.schema, external); + assert.throws(() => ensureStateEngineLayout(rootDir), /hard links|multiple hard links/i); + assert.equal(fs.existsSync(external), true, 'Unrelated hard link must never be deleted'); + assert.equal(fs.statSync(paths.schema).nlink, 2); +}); + + +test('V13-CONC-BOOTSTRAP-04 live initializer staging links are not reclaimed', (t) => { + const rootDir = tempProject(t, 'dk-v13-bootstrap-live-'); + const paths = ensureStateEngineLayout(rootDir); + const temporary = path.join(paths.stateRoot, + 'schema.json.tmp-' + process.pid + '-00000000-0000-4000-8000-000000000001'); + fs.linkSync(paths.schema, temporary); + try { + assert.throws(() => ensureStateEngineLayout(rootDir), /live process/i, + 'A live initializer retains ownership of its temporary publication link'); + assert.equal(fs.existsSync(temporary), true); + assert.equal(fs.statSync(paths.schema).nlink, 2); + } finally { + fs.unlinkSync(temporary); + } + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-P2 JSON object keys including empty, control and Unicode strings survive patch update and deletion', (t) => { + for (const [index, key] of ['', '\n', '\r', '\u0000', 'x\ny', 'é', '__proto__'].entries()) { + const rootDir = tempProject(t, 'dk-v13-patch-key-'); + const entityId = 'literal-key-' + index; + const first = { stateRevision: 1, metadata: { [key]: { count: 1 } } }; + const second = { stateRevision: 2, metadata: { [key]: { count: 2 } } }; + const third = { stateRevision: 3, metadata: {} }; + appendEntityState({ rootDir, entityType: 'generic-state', entityId, + state: first, actorClass: 'regression' }); + appendEntityState({ rootDir, entityType: 'generic-state', entityId, + state: second, actorClass: 'regression' }); + assert.deepEqual(loadStateSnapshot(rootDir, { rebuildIfNeeded: true }) + .entities['generic-state'][entityId].state, second, 'update key ' + JSON.stringify(key)); + appendEntityState({ rootDir, entityType: 'generic-state', entityId, + state: third, actorClass: 'regression' }); + assert.deepEqual(rebuildSnapshotFromEvents(loadCanonicalEvents(rootDir)) + .entities['generic-state'][entityId].state, third, 'delete key ' + JSON.stringify(key)); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); + } +}); + +test('T04-SEC-JSON-01 rejects non-lossless JSON state before canonical commit', (t) => { + const invalid = [ + ['Map', new Map([['secret', 'must-not-disappear']])], + ['Set', new Set(['must-not-disappear'])], + ['Date', new Date('2026-10-31T00:00:00.000Z')], + ['undefined', undefined], + ['NaN', Number.NaN], + ['Infinity', Number.POSITIVE_INFINITY], + ]; + for (const [label, value] of invalid) { + const rootDir = tempProject(t, 'dk-v13-json-lossless-'); + assert.throws(() => appendEntityState({ + rootDir, entityType: 'generic-state', entityId: 'json-regression', + state: { stateRevision: 1, metadata: { value } }, + actorClass: 'json-regression', + }), /JSON-serializable|JSON-compatible|lossless/i, + 'Reject ' + label + ' instead of acknowledging a lossy event'); + const paths = getStateEnginePaths(rootDir); + assert.equal(fs.existsSync(paths.events), false, + label + ' must not be acknowledged or committed to the canonical ledger'); + } +}); + + +test('T04-H67 read-only index lookup must not publish a missing snapshot', (t) => { + const rootDir = tempProject(t, 'dk-v13-h67-index-readonly-'); + appendEntityState({ + rootDir, entityType: 'generic-state', entityId: 'h67', + state: { stateRevision: 1, value: 'acknowledged' }, + actorClass: 'regression', + }); + const paths = getStateEnginePaths(rootDir); + const ledgerBefore = fs.readFileSync(paths.events); + const indexBefore = fs.readFileSync(paths.index); + fs.unlinkSync(paths.snapshot); + + // rebuildIfNeeded:false is a read-only verification boundary. It must not + // silently regenerate a missing materialized snapshot or return an index + // whose source snapshot is absent. + assert.equal(loadStateIndex(rootDir, { rebuildIfNeeded: false }), null); + assert.equal(fs.existsSync(paths.snapshot), false, + 'read-only index lookup must not recreate the missing snapshot'); + assert.deepEqual(fs.readFileSync(paths.events), ledgerBefore, + 'canonical acknowledged ledger must remain untouched'); + assert.deepEqual(fs.readFileSync(paths.index), indexBefore, + 'existing index witness must remain untouched'); + + const recovered = loadStateIndex(rootDir, { rebuildIfNeeded: true }); + assert.equal(recovered.sourceSequence, 1); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-H68 read-only index verification preserves a valid pending journal and missing snapshot', (t) => { + const rootDir = tempProject(t, 'dk-v13-h68-readonly-pending-'); + const paths = getStateEnginePaths(rootDir); + appendEntityState({ + rootDir, entityType: 'generic-state', entityId: 'h68', + state: { stateRevision: 1, value: 'first' }, actorClass: 'regression', + }); + const acknowledgedPrefix = fs.readFileSync(paths.events); + const originalIndex = fs.readFileSync(paths.index); + appendEntityState({ + rootDir, entityType: 'generic-state', entityId: 'h68', + state: { stateRevision: 2, value: 'second' }, actorClass: 'regression', + }); + const completeLedger = fs.readFileSync(paths.events); + const pendingSuffix = completeLedger.subarray(acknowledgedPrefix.length).toString('utf8'); + assert.ok(pendingSuffix.endsWith('\n')); + fs.writeFileSync(paths.events, acknowledgedPrefix); + fs.writeFileSync(paths.index, originalIndex); + fs.unlinkSync(paths.snapshot); + const pending = { + schemaVersion: '1.0.0', + previousByteLength: acknowledgedPrefix.length, + previousHash: 'sha256:' + crypto.createHash('sha256').update(acknowledgedPrefix).digest('hex'), + suffix: pendingSuffix, + }; + fs.writeFileSync(paths.pending, JSON.stringify(pending) + '\n'); + const journalBefore = fs.readFileSync(paths.pending); + + assert.throws( + () => loadStateIndex(rootDir, { rebuildIfNeeded: false }), + /pending|recovery|read.only/i, + 'read-only verification must refuse to recover an interrupted commit', + ); + assert.deepEqual(fs.readFileSync(paths.events), acknowledgedPrefix, + 'read-only verification must not append pending suffix'); + assert.deepEqual(fs.readFileSync(paths.pending), journalBefore, + 'read-only verification must not retire recovery journal'); + assert.equal(fs.existsSync(paths.snapshot), false, + 'read-only verification must not materialize missing snapshot'); + assert.deepEqual(fs.readFileSync(paths.index), originalIndex, + 'read-only verification must not replace index witness'); + + const recovered = loadStateIndex(rootDir, { rebuildIfNeeded: true }); + assert.equal(recovered.sourceSequence, 2); + assert.equal(fs.existsSync(paths.pending), false); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); + +test('T04-H68 read-only index verification never truncates an unacknowledged torn tail', (t) => { + const rootDir = tempProject(t, 'dk-v13-h68-readonly-tail-'); + appendEntityState({ + rootDir, entityType: 'generic-state', entityId: 'h68-tail', + state: { stateRevision: 1, value: 'acknowledged' }, actorClass: 'regression', + }); + const paths = getStateEnginePaths(rootDir); + fs.appendFileSync(paths.events, '{"partial":'); + const ledgerBefore = fs.readFileSync(paths.events); + const snapshotBefore = fs.readFileSync(paths.snapshot); + const indexBefore = fs.readFileSync(paths.index); + + assert.throws( + () => loadStateIndex(rootDir, { rebuildIfNeeded: false }), + /tail|repair|read.only|recovery/i, + 'read-only verification must refuse canonical tail repair', + ); + assert.deepEqual(fs.readFileSync(paths.events), ledgerBefore, + 'read-only verification must preserve the torn tail for recovery'); + assert.deepEqual(fs.readFileSync(paths.snapshot), snapshotBefore); + assert.deepEqual(fs.readFileSync(paths.index), indexBefore); + + const repaired = loadStateIndex(rootDir, { rebuildIfNeeded: true }); + assert.equal(repaired.sourceSequence, 1); + assert.equal(verifyStateEngineIntegrity(rootDir).valid, true); +}); diff --git a/scripts/t04-review-thread-blockers.mjs b/scripts/t04-review-thread-blockers.mjs new file mode 100644 index 000000000..001b303cf --- /dev/null +++ b/scripts/t04-review-thread-blockers.mjs @@ -0,0 +1,121 @@ +// Deterministic review-thread blocker selection for T04 risk-3 acceptance. +// A requested clean review requires zero unresolved comments of any priority. +export function unresolvedReviewThreads(threads) { + if (!Array.isArray(threads)) throw new TypeError('Review thread evidence must be an array'); + return threads.filter(thread => !thread || thread.isResolved !== true); +} + +export function requireCanonicalReviewRequestCommit(body, reviewedCommit) { + if (typeof body !== 'string' || !/^[a-f0-9]{40}$/.test(reviewedCommit)) { + throw new Error('Review request commit evidence is malformed'); + } + // Exactly one unambiguous source-binding field is permitted. An authenticated + // reaction alone has no Git object provenance; ambiguous requests fail closed. + const fields = [...body.matchAll(/^(?:Commit|Immutable source commit):[ \t]*([a-f0-9]{40})[ \t]*$/gm)]; + // Conservatively reject every other plausible Git abbreviation, including + // uppercase identifiers. Hex-only prose may therefore need rewording. + const hashes = body.match(/\b[a-f0-9]{7,40}\b/gi) ?? []; + if (fields.length !== 1 || fields[0][1] !== reviewedCommit || + hashes.length !== 1 || hashes[0] !== reviewedCommit) { + throw new Error('Reviewer scope request must identify exactly one canonical reviewed commit'); + } + return reviewedCommit; +} + +export function parseReviewThreadPage(page) { + if (!page || !Array.isArray(page.nodes) || !page.pageInfo || + typeof page.pageInfo.hasNextPage !== 'boolean' || + (page.pageInfo.endCursor != null && typeof page.pageInfo.endCursor !== 'string') || + (page.pageInfo.hasNextPage && + (typeof page.pageInfo.endCursor !== 'string' || !page.pageInfo.endCursor)) || + page.nodes.some(node => !node || typeof node.isResolved !== 'boolean')) { + throw new Error('Authenticated GitHub review thread page is malformed'); + } + return { + nodes: page.nodes, + hasNextPage: page.pageInfo.hasNextPage, + endCursor: page.pageInfo.endCursor ?? null, + }; +} + +/** + * A COMMENTED review is not a clean attestation just because negative prose + * contains the word PASS. Require an explicit verdict without contradictions. + */ +export function requireCanonicalGithubReviewVerdict(body) { + if (typeof body !== 'string') throw new Error('GitHub reviewer verdict evidence is malformed'); + // Closed schema: no trailers, markdown prose, second findings field or + // unstructured text capable of contradicting a declared clean review. + const lines = body.replace(/\r\n/g, '\n').split('\n'); + if (lines.length === 1) { + throw new Error('Independent GitHub review requires exactly one canonical PASS verdict'); + } + if (lines.length !== 2 && lines.length !== 4) { + throw new Error('Conflicting failure or finding marker in GitHub review: unexpected response fields'); + } + const [verdict, findings] = lines.slice(-2); + if (!/^(?:Review verdict|Verdict): PASS$/.test(verdict)) { + throw new Error('Independent GitHub review requires exactly one canonical PASS verdict'); + } + if (findings !== 'Findings: none') { + throw new Error('Conflicting failure or finding marker in GitHub review: findings not clean'); + } + if (lines.length === 4 && + (!/^Reviewed commit: [a-f0-9]{40}$/.test(lines[0]) || + !/^Review role: (?:architecture-reviewer|code-reviewer|security-reviewer)$/.test(lines[1]))) { + throw new Error('Conflicting failure or finding marker in GitHub review: noncanonical source or role'); + } + return 'PASS'; +} + +/** + * Construct a single-role immutable review request with exactly one SHA. + * The result must still be independently authored and evaluated externally. + */ +export function formatCanonicalT04ReviewRequest(role, reviewedCommit, scope) { + if (!['architecture-reviewer', 'code-reviewer', 'security-reviewer'].includes(role) || + typeof scope !== 'string' || !scope.trim() || /[\r\n]/.test(scope)) { + throw new Error('T04 reviewer request role/scope is invalid'); + } + const body = [ + '@codex review', + 'Independent role: ' + role, + 'Commit: ' + reviewedCommit, + 'Review scope: ' + scope, + 'Report findings with priority; use the exact closed four-field response schema on a clean result. A finding must not be concealed by a PASS.', + ].join('\n'); + requireCanonicalReviewRequestCommit(body, reviewedCommit); + return body; +} + +function requireCanonicalRole(body, label, expectedRole) { + const roles = ['architecture-reviewer', 'code-reviewer', 'security-reviewer']; + const fields = [...body.matchAll(new RegExp('^' + label + ':[ \\t]*(.*)$', 'gmi'))]; + if (!roles.includes(expectedRole) || fields.length !== 1 || + fields[0][0] !== label + ': ' + expectedRole) { + throw new Error('Reviewer evidence requires exactly one canonical ' + label); + } +} + +export function requireCanonicalGithubReviewRequest(request, expectedRole, reviewedCommit) { + if (!request || request.issue_url !== 'https://api.github.com/repos/eybersjp/development-kit/issues/92' || + typeof request.body !== 'string' || !request.body.includes('@codex')) { + throw new Error('Authenticated reviewer scope request must bind PR #92'); + } + requireCanonicalRole(request.body, 'Independent role', expectedRole); + requireCanonicalReviewRequestCommit(request.body, reviewedCommit); + return reviewedCommit; +} + +export function requireCanonicalGithubReviewResponse(review, expectedRole, reviewedCommit) { + if (!review || review.commit_id !== reviewedCommit || typeof review.body !== 'string') { + throw new Error('Authenticated review metadata must bind the immutable reviewed source'); + } + requireCanonicalRole(review.body, 'Review role', expectedRole); + // Reuse the same conservative source-token policy with a response-only label. + if (/^(?:Commit|Immutable source commit):/mi.test(review.body)) { + throw new Error('Review response contains an alternative source-binding field'); + } + requireCanonicalReviewRequestCommit(review.body.replace(/^Reviewed commit:/gm, 'Commit:'), reviewedCommit); + return requireCanonicalGithubReviewVerdict(review.body); +} diff --git a/scripts/t04-review-thread-blockers.test.mjs b/scripts/t04-review-thread-blockers.test.mjs new file mode 100644 index 000000000..d68808a77 --- /dev/null +++ b/scripts/t04-review-thread-blockers.test.mjs @@ -0,0 +1,177 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import * as reviewParsers from './t04-review-thread-blockers.mjs'; +import { + unresolvedReviewThreads, + requireCanonicalReviewRequestCommit, + parseReviewThreadPage, + requireCanonicalGithubReviewVerdict, + formatCanonicalT04ReviewRequest, +} from './t04-review-thread-blockers.mjs'; + +test('T04-A01 unresolved P2 and all other finding priorities prevent acceptance', () => { + const p2 = { id: 'p2', isResolved: false, comments: { nodes: [{ body: '[P2] broken validator' }] } }; + const p1 = { id: 'p1', isResolved: false, comments: { nodes: [{ body: '[P1] lost data' }] } }; + const minor = { id: 'minor', isResolved: false, comments: { nodes: [{ body: 'Unanswered question' }] } }; + assert.deepEqual(unresolvedReviewThreads([p2, p1, minor]).map(x => x.id), ['p2', 'p1', 'minor']); +}); + +test('RI-01 source binding rejects abbreviated and uppercase alternate identifiers', () => { + const source = 'a'.repeat(40); + for (const alternate of ['bbbbbbb', 'B'.repeat(40), source.toUpperCase(), source.slice(0, 10)]) { + assert.throws(() => requireCanonicalReviewRequestCommit('Commit: ' + source + '\nBaseline: ' + alternate, source)); + } +}); + +test('RI-01 clean verdict requires one canonical findings declaration and no structured severity', () => { + for (const findings of ['', 'Findings: MAJOR Windows journal publication may lose data', + 'Findings: none\nSeverity: CRITICAL', 'Findings: none\nStatus: OPEN', + 'Findings: none\nFindings: none', 'Findings: none\n[MAJOR] data loss']) { + assert.throws(() => requireCanonicalGithubReviewVerdict('Verdict: PASS\n' + findings)); + } +}); + +test('RI-02 authenticated scope request binds PR and exactly one role', () => { + const source = 'a'.repeat(40); + const request = { issue_url: 'https://api.github.com/repos/eybersjp/development-kit/issues/92', + body: '@codex\nIndependent role: code-reviewer\nCommit: ' + source }; + assert.equal(typeof reviewParsers.requireCanonicalGithubReviewRequest, 'function'); + assert.doesNotThrow(() => reviewParsers.requireCanonicalGithubReviewRequest(request, 'code-reviewer', source)); + for (const invalid of [{ ...request, issue_url: request.issue_url.replace('/92', '/70') }, + { ...request, issue_url: request.issue_url.replace('/92', '/71') }, + { ...request, issue_url: undefined }, + { ...request, body: request.body + '\nIndependent role: code-reviewer' }, + { ...request, body: request.body + '\nIndependent role: security-reviewer' }]) { + assert.throws(() => reviewParsers.requireCanonicalGithubReviewRequest(invalid, 'code-reviewer', source)); + } +}); + +test('RI-01 authenticated response binds unique anchored role and full API source', () => { + const source = 'a'.repeat(40); + const review = { commit_id: source, body: 'Reviewed commit: ' + source + + '\nReview role: code-reviewer\nVerdict: PASS\nFindings: none' }; + assert.equal(typeof reviewParsers.requireCanonicalGithubReviewResponse, 'function'); + assert.doesNotThrow(() => reviewParsers.requireCanonicalGithubReviewResponse(review, 'code-reviewer', source)); + for (const invalid of [{ ...review, commit_id: 'b'.repeat(40) }, + { ...review, body: review.body.replace(source, source.slice(0, 10)) }, + { ...review, body: review.body + '\nReview role: code-reviewer' }, + { ...review, body: review.body + '\nReview role: security-reviewer' }, + { ...review, body: review.body.replace('Review role:', 'Example Review role:') }, + { ...review, body: review.body + '\nBaseline: bbbbbbb' }]) { + assert.throws(() => reviewParsers.requireCanonicalGithubReviewResponse(invalid, 'code-reviewer', source)); + } +}); + +test('RI-01 final pagination cursor rejects malformed types', () => { + for (const endCursor of [123, {}, []]) { + assert.throws(() => parseReviewThreadPage({ nodes: [], pageInfo: { hasNextPage: false, endCursor } })); + } +}); + +test('RI-01 clean declaration cannot suppress a separate structured minor finding', () => { + for (const marker of ['Severity: MINOR', 'Finding: data loss', '[P3] unanswered question', 'Status: PENDING']) { + assert.throws(() => requireCanonicalGithubReviewVerdict('Verdict: PASS\nFindings: none\n' + marker)); + } +}); + +test('T04-A02 resolved threads cannot block a clean exact-source review', () => { + assert.deepEqual(unresolvedReviewThreads([{ id: 'p2', isResolved: true }, { id: 'p1', isResolved: true }]), []); +}); + +test('T04-A03 malformed or uncertain review thread state fails closed', () => { + assert.equal(unresolvedReviewThreads([{ id: 'missing-resolution' }, null]).length, 2); + assert.throws(() => unresolvedReviewThreads(null), /must be an array/i); +}); + +test('T04-A04 reviewer clean reaction requires one unambiguous anchored commit field', () => { + const current = 'a'.repeat(40); + const other = 'b'.repeat(40); + assert.equal(requireCanonicalReviewRequestCommit('Commit: ' + current, current), current); + assert.equal(requireCanonicalReviewRequestCommit('Immutable source commit: ' + current, current), current); + assert.throws(() => requireCanonicalReviewRequestCommit( + 'Commit: ' + other + '\nReference baseline: ' + current, current, + ), /exactly one canonical/i); + assert.throws(() => requireCanonicalReviewRequestCommit( + 'Commit: ' + current + '\nIgnore other commit ' + other, current, + ), /exactly one canonical/i); + assert.throws(() => requireCanonicalReviewRequestCommit( + 'Commit: ' + current + '\nCommit: ' + current, current, + ), /exactly one canonical/i); + assert.throws(() => requireCanonicalReviewRequestCommit( + 'Exclude: ' + current + '\nCommit: ' + other, current, + ), /exactly one canonical/i); + assert.throws(() => requireCanonicalReviewRequestCommit( + 'context mentions ' + current, current, + ), /exactly one canonical/i); +}); + +test('T04-A05 authenticated GraphQL thread pages fail closed on missing nodes or pagination', () => { + const good = { nodes: [{ id: 'thread', isResolved: false }], + pageInfo: { hasNextPage: false, endCursor: null } }; + assert.deepEqual(parseReviewThreadPage(good).nodes, good.nodes); + assert.deepEqual(parseReviewThreadPage({ nodes: [], + pageInfo: { hasNextPage: true, endCursor: 'cursor-1' } }).endCursor, 'cursor-1'); + assert.throws(() => parseReviewThreadPage({ ...good, nodes: null }), /malformed/i); + assert.throws(() => parseReviewThreadPage({ ...good, pageInfo: null }), /malformed/i); + assert.throws(() => parseReviewThreadPage({ ...good, + pageInfo: { hasNextPage: true, endCursor: null } }), /malformed/i); + assert.throws(() => parseReviewThreadPage({ ...good, nodes: [null] }), /malformed/i); + assert.throws(() => parseReviewThreadPage({ ...good, + nodes: [{ id: 'unknown' }] }), /malformed/i); +}); + +test('T04-A06 COMMENTED review requires a canonical PASS without conflicting findings', () => { + const valid = 'Reviewed commit: ' + 'a'.repeat(40) + + '\nReview role: code-reviewer\nReview verdict: PASS\nFindings: none'; + assert.equal(requireCanonicalGithubReviewVerdict(valid), 'PASS'); + for (const body of [ + 'cannot PASS', + 'Review role: code-reviewer\nCannot PASS until P1 fixed', + 'Verdict: PASS\nCannot PASS until the durability issue is fixed', + 'Verdict: PASS\nP1 Windows crash durability still open', + 'Verdict: PASS\nVerdict: FAIL', + 'Verdict: FAIL', + 'Verdict: PASS\nReview verdict: PASS', + 'Verdict: PASS\nBlocked by unresolved review', + 'Verdict: PASS\nnot PASS', + ]) { + assert.throws(() => requireCanonicalGithubReviewVerdict(body), + /canonical PASS verdict|Conflicting failure or finding marker/i, body); + } +}); + +test('T04-A07 reject extra free-text contradicting zero-findings review', () => { + const full = 'Reviewed commit: ' + 'a'.repeat(40) + + '\nReview role: code-reviewer\nVerdict: PASS\nFindings: none'; + assert.equal(reviewParsers.requireCanonicalGithubReviewResponse({ + commit_id: 'a'.repeat(40), body: full, + }, 'code-reviewer', 'a'.repeat(40)), 'PASS'); + for (const additional of [ + 'The restore loses the original tree after a power outage', + 'Possible loss of user data on Windows', + 'Further investigation is required', + 'Verdict: PASS', + 'Findings: none', + '', + ]) { + assert.throws(() => reviewParsers.requireCanonicalGithubReviewResponse({ + commit_id: 'a'.repeat(40), body: full + '\n' + additional, + }, 'code-reviewer', 'a'.repeat(40)), /Conflicting failure|canonical/i); + } +}); + +test('T04-A08 generated reviewer request has one immutable SHA and exactly one role', () => { + const source = 'a'.repeat(40); + const roles = ['code-reviewer', 'architecture-reviewer', 'security-reviewer']; + for (const role of roles) { + const body = formatCanonicalT04ReviewRequest(role, source, 'Inspect precise T04 source and evidence.'); + assert.equal((body.match(new RegExp(source, 'g')) || []).length, 1); + assert.equal(reviewParsers.requireCanonicalGithubReviewRequest({ + issue_url: 'https://api.github.com/repos/eybersjp/development-kit/issues/92', + body, + }, role, source), source); + assert.throws(() => formatCanonicalT04ReviewRequest( + role, source, 'Inspect a different revision ' + 'b'.repeat(40) + '.', + ), /one canonical reviewed commit/i); + } +}); diff --git a/scripts/v013-state-engine-v2-contract.mjs b/scripts/v013-state-engine-v2-contract.mjs new file mode 100644 index 000000000..5a9f9ad16 --- /dev/null +++ b/scripts/v013-state-engine-v2-contract.mjs @@ -0,0 +1,221 @@ +#!/usr/bin/env node + +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { + checkContractStaleness, + ensureDevelopmentContract, + validateDevelopmentContract, +} from '../runtime/orchestration/development-contract.mjs'; +import { resolveContractGateProfiles } from '../runtime/orchestration/gate-profiles.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); + +const task = { + id: 'DKF130-T04', + projectId: 'proj_cad505c1-901d-46f0-a0d1-297598bdae18', + status: 'approved', + objective: 'Complete the deferred State Engine V2 increment on top of released v0.12.1, including canonical event-backed state, verified legacy migration, bounded state-file growth, and full Windows legacy-backup restore/recovery with persistent crash-recovery witnesses.', + scope: { + in: [ + 'runtime/orchestration/state-engine-v2.mjs', + 'runtime/orchestration/state-engine-migration.mjs', + 'runtime/autopilot/state-store.mjs', + 'runtime/api/runtime-api-service.mjs', + 'runtime/orchestration/orchestration-run.mjs', + 'runtime/orchestration/index.mjs', + 'schemas/state-engine-*.schema.json', + 'scripts/windows-durable-rename.ps1', + 'scripts/state-engine-v2*.test.mjs', + 'scripts/state-engine-v013.test.mjs', + 'scripts/state-engine-v2-benchmark.mjs', + 'scripts/v013-state-engine-v2-contract.mjs', + '.github/workflows/ci.yml', + 'package.json', + 'docs/', + 'memory.md', + ], + out: [ + 'Application-specific or industry-specific state semantics', + 'Changing released Lifecycle Instance semantics', + 'Changing Discovery and Controlled Re-entry semantics', + 'Weakening Gate Profiles or Verification Extension acceptance', + 'Distributed cross-host locking', + 'Same-account hostile writer hardening tracked separately from Issue #73', + 'Publishing v0.13.0', + 'Deleting retained legacy state before a later explicit cleanup policy', + ], + }, + requirements: [ + 'DKF-120-AC-012: canonical development-state history is append-only, deterministically ordered and integrity chained.', + 'DKF-120-AC-013: materialized current state rebuilds deterministically from canonical history.', + 'DKF-120-AC-014: acceleration index deletion or corruption is rebuildable without semantic change.', + 'DKF-120-AC-015: supported legacy state migration is idempotent, fail-closed, recoverable and semantically equivalent before cutover.', + 'DKF-120-AC-034: canonical transition-state file growth is bounded rather than proportional to normal transition count.', + 'Preserve v0.12.1 public command and T01-T03/T05-T10 behavior while replacing persistence authority underneath released interfaces.', + 'A missing Windows pending-commit journal must not make an unambiguous torn trailing canonical append unrecoverable.', + 'Windows restore must retain persistent transaction witnesses and support deterministic recovery on the declared supported storage profile.', + 'Unsupported Windows storage profiles must fail before destructive target-root mutation and preserve recoverable assets.', + 'Independent code, architecture and security review remain required for risk-3 acceptance.', + ], + acceptanceCriteria: [ + { + id: 'DKF-120-AC-012', + statement: 'State history is append-only and canonical, with deterministic ordering and integrity chaining.', + source: 'DKF-120-AC-012', + verificationType: ['test'], + requiredEvidence: true, + }, + { + id: 'DKF-120-AC-013', + statement: 'Current state is materialized from canonical history and can be rebuilt deterministically.', + source: 'DKF-120-AC-013', + verificationType: ['test'], + requiredEvidence: true, + }, + { + id: 'DKF-120-AC-014', + statement: 'The acceleration index can be deleted or corrupted and rebuilt without changing semantic state.', + source: 'DKF-120-AC-014', + verificationType: ['test'], + requiredEvidence: true, + }, + { + id: 'DKF-120-AC-015', + statement: 'Supported legacy state migrates idempotently, fail-closed, recoverably and semantically equivalently before cutover.', + source: 'DKF-120-AC-015', + verificationType: ['test', 'security'], + requiredEvidence: true, + }, + { + id: 'DKF-120-AC-034', + statement: 'Canonical transition-state file growth is bounded and no longer proportional to normal transition count.', + source: 'DKF-120-AC-034', + verificationType: ['test', 'architecture-verification'], + requiredEvidence: true, + }, + ], + architectureConstraints: [ + 'events.jsonl is canonical; snapshot.json is materialized/rebuildable; index.db is disposable acceleration only.', + 'SQLite may not become the sole source of truth.', + 'Fresh projects may use V2 directly; legacy projects may cut over only after verified migration-complete evidence.', + 'Historical v0.12 T04 branches are source material only and may not overwrite later v0.12.1 feature semantics.', + 'State Engine V2 remains generic DKF development-state infrastructure.', + 'The canonical ledger may automatically repair only unambiguous final append damage; mid-history or hash-chain ambiguity fails closed.', + ], + designConstraints: [], + securityConstraints: [ + 'No symlink, junction, path traversal or hard-link alias may redirect canonical state or restore writes outside approved boundaries.', + 'Recovery may never destroy the last authenticated recoverable copy.', + 'Windows restore requires positive supported-storage identification before destructive root mutation.', + 'Required Windows durability helper failures must propagate and may not silently fall back to plain rename.', + 'Pending journals and restore transaction records are recovery witnesses and must not be treated as advisory summaries.', + ], + risk: { + level: 3, + reasons: [ + 'Persistent-state migration and recovery are control-plane critical.', + 'Incorrect durability or cutover could lose or falsely advance authoritative development state.', + 'Windows restore mutates project-root directory entries and requires fail-closed recovery semantics.', + ], + }, + requiredVerification: ['tests'], + requiredReviewers: ['code-reviewer'], + correctionPolicy: { maxAttempts: 3 }, + approvalPolicy: { + requiredGateProfileIds: ['gp-high-assurance'], + }, + executionSafety: { + resourceScope: 'project-only', + destructiveOperations: 'explicit-approval', + remoteMutation: 'forbidden', + }, + configurationDependencies: [], + targetBinding: { + primary: 'dkf-framework', + affected: ['dkf-framework'], + verification: ['dkf-framework'], + }, +}; + +const authoritativeSources = [ + { + path: 'docs/04-architecture/dkf-v0.13-state-engine-v2-specification.md', + kind: 'specification', + authority: 'required', + sections: ['3. Required canonical layout', '4. Acceptance criteria restored for v0.13', '6. Runtime commit durability contract', '7. Windows legacy-backup restore/recovery contract', '11. Required independent gates'], + }, + { + path: 'docs/04-architecture/dkf-v0.13-t04-release-authority.md', + kind: 'product-owner-release-authority', + authority: 'required', + sections: ['1. Release-line decision', '2. Product Owner authority', '3. Windows blocker', '6. Acceptance boundary'], + }, + { + path: 'CHANGELOG.md', + kind: 'released-baseline', + authority: 'required', + sections: ['0.12.1', '0.12.0'], + }, + { + path: 'docs/04-architecture/dkf-scale-context-iteration-v0.12-technical-design.md', + kind: 'historical-technical-design', + authority: 'supporting', + sections: ['7. State Engine V2'], + }, + { + path: 'docs/04-architecture/dkf-scale-context-iteration-v0.12-implementation-plan.md', + kind: 'historical-plan', + authority: 'supporting', + sections: ['6. Task DKF120-T04 — State Engine V2'], + }, +]; + +const result = ensureDevelopmentContract({ + rootDir: ROOT, + projectId: task.projectId, + task, + authoritativeSources, + contractId: 'INC-DKF130-T04', +}); + +validateDevelopmentContract(result.contract); +if (!result.contract.workspaceTargets || result.contract.workspaceTargets.primary !== 'dkf-framework') { + throw new Error('v0.13 T04 must use the dkf-framework target-aware Development Contract'); +} + +const gates = resolveContractGateProfiles(result.contract, { + securitySensitive: true, + architectureSensitive: true, +}); +const expectedVerification = ['architecture-verification', 'security-audit', 'specification', 'tests']; +const expectedReviewers = ['architecture-reviewer', 'code-reviewer', 'security-reviewer']; +if (JSON.stringify(gates.verification) !== JSON.stringify(expectedVerification)) { + throw new Error('Unexpected v0.13 T04 verification gates: ' + gates.verification.join(', ')); +} +if (JSON.stringify(gates.reviewers) !== JSON.stringify(expectedReviewers)) { + throw new Error('Unexpected v0.13 T04 reviewer gates: ' + gates.reviewers.join(', ')); +} +if (JSON.stringify(gates.controlDomains) !== JSON.stringify(['security'])) { + throw new Error('Unexpected v0.13 T04 control domains: ' + gates.controlDomains.join(', ')); +} +if (gates.humanApprovals.length > 0) { + throw new Error('v0.13 T04 unexpectedly derived a human approval gate'); +} + +const staleness = checkContractStaleness(result.contract, ROOT); +if (staleness.stale) throw new Error('DKF130-T04 Development Contract is stale immediately after creation'); + +process.stdout.write(JSON.stringify({ + contractId: result.contract.contractId, + taskId: result.contract.taskId, + schemaVersion: result.contract.schemaVersion, + created: result.created, + sourceFingerprint: result.contract.sourceFingerprint, + acceptanceCriteria: result.contract.acceptanceCriteria.map((criterion) => criterion.id), + gates, + riskLevel: result.contract.risk.level, + targetBinding: result.contract.workspaceTargets, + stale: staleness.stale, +}, null, 2) + '\n'); diff --git a/scripts/v013-t04-acceptance-ci-contract.test.mjs b/scripts/v013-t04-acceptance-ci-contract.test.mjs new file mode 100644 index 000000000..ea09eb993 --- /dev/null +++ b/scripts/v013-t04-acceptance-ci-contract.test.mjs @@ -0,0 +1,210 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const workflow = fs.readFileSync(path.join(root, '.github/workflows/ci.yml'), 'utf8'); + +test('T04-A13 v0.13 integration has a distinct source-bound risk-3 acceptance job', () => { + assert.match(workflow, /^ t04_acceptance:\s*$/m, + 'v0.13 T04 must not rely on green platform tests as its acceptance verdict'); + const job = workflow.split(/^ t04_acceptance:\s*$/m)[1]?.split(/^ [a-z][\w-]*:\s*$/m)[0] ?? ''; + assert.match(job, /^ needs:\s*validate\s*$/m, + 'independent acceptance must depend on successful Ubuntu AND Windows matrix validation'); + assert.match(job, /github\.head_ref\s*==\s*'feature\/v0\.13-state-engine-v2'/, + 'historical/diagnostic PRs must never receive production T04 acceptance'); + assert.match(job, /scripts\/v013-t04-acceptance-gate\.mjs/, + 'the acceptance job must run a deterministic risk-3 gate, not a placeholder echo'); + assert.match(job, /GITHUB_TOKEN:/, + 'independent reviews and CI provenance must be checked with authenticated GitHub evidence'); +}); + +test('T04-A14 acceptance gate source exists and requires independent immutable evidence', () => { + const gatePath = path.join(root, 'scripts/v013-t04-acceptance-gate.mjs'); + assert.ok(fs.existsSync(gatePath), 'missing deterministic v0.13 T04 acceptance gate'); + const source = fs.readFileSync(gatePath, 'utf8'); + for (const required of ['decideAcceptance', 'reviewedCommit', 'architecture-reviewer', + 'code-reviewer', 'security-reviewer', 'GITHUB_RUN_ID', 'GITHUB_TOKEN', + 'CLEANUP_SAFE', 'windows']) { + assert.ok(source.includes(required), 'acceptance gate lacks required evidence check: ' + required); + } +}); + +test('T04-A15 risk-3 gate parses, fails closed outside authenticated CI and never uses historical PR #70', () => { + const gate = path.join(root, 'scripts/v013-t04-acceptance-gate.mjs'); + const syntax = spawnSync(process.execPath, ['--check', gate], { cwd: root, encoding: 'utf8' }); + assert.equal(syntax.status, 0, syntax.stderr); + const isolated = spawnSync(process.execPath, [gate], { + cwd: root, encoding: 'utf8', env: { + ...process.env, GITHUB_ACTIONS: 'false', GITHUB_TOKEN: '', + GITHUB_REPOSITORY: 'eybersjp/development-kit', + }, + }); + assert.notEqual(isolated.status, 0, 'Unauthenticated local invocation must never produce ACCEPTED'); + assert.doesNotMatch(isolated.stdout, /DKF130-T04 ACCEPTED/); + const code = fs.readFileSync(gate, 'utf8'); + assert.match(code, /decideAcceptance\(\{/); + assert.match(code, /assert\.equal\(decision\.state, 'ACCEPTED'/); + assert.match(code, /physical\.artifactUrl/); + assert.match(code, /validationRunIds/); + assert.match(code, /review\.commit_id, reviewedCommit/); + assert.match(code, /review\.state, 'APPROVED'/); + assert.match(code, /securityControls/); +}); + +test('T04-A16 acceptance workflow reads exact PR head with least privilege', () => { + const job = workflow.split(/^ t04_acceptance:\s*$/m)[1]?.split(/^ [a-z][\w-]*:\s*$/m)[0] ?? ''; + assert.match(job, /ref: \$\{\{ github\.event\.pull_request\.head\.sha \}\}/); + assert.match(job, /persist-credentials: false/); + assert.match(job, /contents: read/); + assert.match(job, /pull-requests: read/); + assert.match(job, /issues: read/); + assert.match(job, /actions: read/); + assert.doesNotMatch(job, /contents: write|pull-requests: write|issues: write|actions: write/); +}); + +test('T04-A17 both platform validations execute the immutable PR head, never a synthetic merge', () => { + const validate = workflow.split(/^ validate:\s*$/m)[1]?.split(/^ [a-z][\w-]*:\s*$/m)[0] ?? ''; + const checkout = validate.split(/ - name: Check out repository\s*\n/)[1]?.split(/ - name: /)[0] ?? ''; + assert.match(checkout, /uses: actions\/checkout@v7/); + assert.match(checkout, /ref: \$\{\{ github\.event\.pull_request\.head\.sha \|\| github\.sha \}\}/, + 'Both Ubuntu and Windows must validate the exact reviewed head, not refs/pull/N/merge'); + assert.match(checkout, /persist-credentials: false/, + 'Validation must not retain checkout credentials from untrusted PR source'); +}); + +test('T04-A18 acceptance gate binds validation receipts to authentic full CI workflow and mandatory steps', () => { + const gate = fs.readFileSync(path.join(root, 'scripts/v013-t04-acceptance-gate.mjs'), 'utf8'); + assert.match(gate, /run\.path, '\.github\/workflows\/ci\.yml'/, + 'same-SHA workflow run IDs must identify the authoritative full CI workflow, not a synthetic green workflow'); + assert.match(gate, /run\.repository\?\.full_name, REPO/, + 'cross-repository validation runs must never count toward source acceptance'); + for (const step of [ + 'Validate v0.13 State Engine V2 increment', + 'Validate v0.13 full compatibility regression', + 'Verify v0.13 risk-3 acceptance job contract (negative control)', + ]) { + assert.ok(gate.includes(step), + 'each OS validation must have a successful mandatory v0.13 step: ' + step); + } + assert.match(gate, /step\.conclusion === 'success'/, + 'the existence of a named validation step is insufficient without its successful conclusion'); +}); + +test('T04-A19 later review revocation or stale reapproval cannot be hidden by an earlier receipt', async () => { + const { assertCurrentRoleApproval } = await import('./v013-t04-review-provenance.mjs'); + const gate = fs.readFileSync(path.join(root, 'scripts/v013-t04-acceptance-gate.mjs'), 'utf8'); + assert.match(gate, /assertCurrentRoleApproval\(review, allSubmittedReviews, reviewedCommit\)/, + 'production acceptance must validate the latest effective GitHub review, not only the receipt ID'); + + const sha = 'a'.repeat(40); + const other = 'b'.repeat(40); + const approval = { id: 101, state: 'APPROVED', commit_id: sha, + user: { id: 90322, login: 'independent-code-reviewer' }, submitted_at: '2026-10-30T10:00:00Z' }; + const comment = { ...approval, id: 102, state: 'COMMENTED', + submitted_at: '2026-10-30T10:30:00Z' }; + const revoked = { ...approval, id: 103, state: 'CHANGES_REQUESTED', + submitted_at: '2026-10-30T11:00:00Z' }; + const staleReapproval = { ...approval, id: 104, commit_id: other, + submitted_at: '2026-10-30T12:00:00Z' }; + assert.doesNotThrow(() => assertCurrentRoleApproval(approval, [approval, comment], sha)); + assert.throws(() => assertCurrentRoleApproval(approval, [approval, revoked], sha), + /superseded|revoked|effective/i); + assert.throws(() => assertCurrentRoleApproval(approval, [approval, staleReapproval], sha), + /superseded|stale|effective/i); + assert.throws(() => assertCurrentRoleApproval(approval, + [{ ...approval, state: 'DISMISSED' }], sha), /approved|dismissed|effective/i); + assert.throws(() => assertCurrentRoleApproval(approval, [approval, { + ...approval, id: 105, state: 'CHANGES_REQUESTED', + submitted_at: '2026-10-30T13:00:00Z', + }], sha), /superseded|revoked|effective/i); +}); + + +test('T04-A21 ACCEPTED verdict must be uploaded durably, only after a successful gate', () => { + const job = workflow.split(/^ t04_acceptance:\s*$/m)[1]?.split(/^ [a-z][\w-]*:\s*$/m)[0] ?? ''; + const upload = job.split(/ - name: Preserve canonical T04 verdict evidence\s*\n/)[1] + ?.split(/ - name: /)[0] ?? ''; + assert.ok(upload, 'T04 acceptance must persist its verdict outside the ephemeral runner'); + assert.match(upload, /if: \$\{\{ success\(\) \}\}/, + 'A failed acceptance gate must never upload a purported ACCEPTED verdict'); + assert.match(upload, /uses: actions\/upload-artifact@v4/, + 'Canonical ACCEPTED record must be retained as a GitHub Actions artifact'); + assert.match(upload, /name: dkf-v013-t04-acceptance-evidence-\$\{\{ github\.event\.pull_request\.head\.sha \}\}/, + 'Verdict artifact must identify its exact immutable reviewed source'); + assert.match(upload, /path: \.development-kit\/acceptance\/DKF130-T04\.json/, + 'Upload only the canonical deterministic Development Contract verdict'); + assert.match(upload, /if-no-files-found: error/, + 'Missing canonical verdict must fail the acceptance job, never silently pass'); + assert.match(upload, /include-hidden-files: true/, + 'The canonical verdict lives in a hidden .development-kit directory and must be included'); + assert.doesNotMatch(upload, /if-no-files-found: ignore/); +}); + + +test('T04-A22 a security-control witness cannot cite an unrelated green workflow', () => { + const gate = fs.readFileSync(path.join(root, 'scripts/v013-t04-acceptance-gate.mjs'), 'utf8'); + assert.match(gate, /validationRuns\.has\(witness\.workflowRunId\)/, + 'Each security-control witness must cite one of the authenticated, exact-SHA, full Ubuntu+Windows validation runs, not any unrelated green workflow'); +}); + + +test('T04-A23 physical NTFS power-loss approval must remain effective after later reviews', async () => { + const gate = fs.readFileSync(path.join(root, 'scripts/v013-t04-acceptance-gate.mjs'), 'utf8'); + assert.match(gate, /assertCurrentRoleApproval\(physicalReview, allSubmittedReviews, reviewedCommit\)/, + 'Physical NTFS approval must be checked against the complete authenticated review timeline; an earlier APPROVED receipt can be revoked'); + + const { assertCurrentRoleApproval } = await import('./v013-t04-review-provenance.mjs'); + const sha = 'a'.repeat(40); + const approval = { id: 901, state: 'APPROVED', commit_id: sha, + user: { id: 90323, login: 'independent-ntfs-reviewer' }, submitted_at: '2026-10-31T08:00:00Z' }; + const revoked = { ...approval, id: 902, state: 'CHANGES_REQUESTED', + submitted_at: '2026-10-31T08:30:00Z' }; + const staleReapproval = { ...approval, id: 903, commit_id: 'b'.repeat(40), + submitted_at: '2026-10-31T09:00:00Z' }; + assert.doesNotThrow(() => assertCurrentRoleApproval(approval, [approval], sha)); + assert.throws(() => assertCurrentRoleApproval(approval, [approval, revoked], sha), + /superseded|revoked|effective/i); + assert.throws(() => assertCurrentRoleApproval(approval, [approval, staleReapproval], sha), + /superseded|stale|effective/i); +}); + +test('T04-A24 immutable reviewer identity prevents account-rename revocation bypass', async () => { + const { assertCurrentRoleApproval } = await import('./v013-t04-review-provenance.mjs'); + const gate = fs.readFileSync(path.join(root, 'scripts/v013-t04-acceptance-gate.mjs'), 'utf8'); + assert.match(gate, /review\.user\.id/); + assert.match(gate, /pr\.user\.id/); + assert.match(gate, /physicalReview\.user\.id/); + const sha = 'a'.repeat(40); + const prior = { id: 2401, state: 'APPROVED', commit_id: sha, + user: { id: 90321, login: 'reviewer-before-rename' }, + submitted_at: '2026-10-31T08:00:00Z' }; + const revoked = { ...prior, id: 2402, state: 'CHANGES_REQUESTED', + user: { id: 90321, login: 'reviewer-after-rename' }, + submitted_at: '2026-10-31T09:00:00Z' }; + const stale = { ...prior, id: 2403, commit_id: 'b'.repeat(40), + user: { id: 90321, login: 'reviewer-after-rename' }, + submitted_at: '2026-10-31T10:00:00Z' }; + assert.throws(() => assertCurrentRoleApproval(prior, [prior, revoked], sha), + /superseded|revoked|effective/i); + assert.throws(() => assertCurrentRoleApproval(prior, [prior, stale], sha), + /superseded|stale|effective/i); +}); + + +test('T04-A25 full platform-validation witnesses cannot require the overall run to pass the independent acceptance job', () => { + // The first full runs on a reviewed source legitimately fail the separate + // independent-acceptance job while both full OS validation jobs pass. + // Requiring overall run.conclusion=success here creates an acceptance + // deadlock: security controls cannot cite those otherwise valid runs. + const gate = fs.readFileSync(path.join(root, 'scripts/v013-t04-acceptance-gate.mjs'), 'utf8'); + assert.match(gate, /validationRuns\.has\(witness\.workflowRunId\)/, + 'A control must still cite an authenticated, exact-source full-validation run'); + assert.match(gate, /assert\.equal\(witnessRun\.head_sha, reviewedCommit\)/, + 'A control cannot cite a different source'); + assert.doesNotMatch(gate, /witnessRun\.conclusion\s*,\s*'success'/, + 'The overall CI run can fail solely because the separate independent acceptance gate correctly fails closed; validate both OS jobs and mandatory steps instead'); +}); diff --git a/scripts/v013-t04-acceptance-gate.mjs b/scripts/v013-t04-acceptance-gate.mjs new file mode 100644 index 000000000..9d2b05789 --- /dev/null +++ b/scripts/v013-t04-acceptance-gate.mjs @@ -0,0 +1,338 @@ +#!/usr/bin/env node +// DKF130-T04 risk-3 acceptance: authenticated external evidence, never CI-green inference. +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { loadDevelopmentContract, checkContractStaleness } from '../runtime/orchestration/development-contract.mjs'; +import { decideAcceptance, validateAcceptanceRecord } from '../runtime/orchestration/acceptance-engine.mjs'; +import { createVerificationRecord, evaluateControlCoverage, getRunDirectory, + persistVerificationRecord, persistControlManifest } from '../runtime/orchestration/evidence-store.mjs'; +import { createReviewResult } from '../runtime/orchestration/review-result.mjs'; +import { detectArchitectureDrift } from '../runtime/orchestration/architecture-drift.mjs'; +import { assertCurrentRoleApproval } from './v013-t04-review-provenance.mjs'; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); +const REPO = 'eybersjp/development-kit'; +const RECEIPTS = 'docs/04-architecture/dkf-v013-t04-independent-review-receipts.json'; +const ROLES = ['code-reviewer', 'architecture-reviewer', 'security-reviewer']; +const SHA = /^[a-f0-9]{40}$/; +const runId = process.env.GITHUB_RUN_ID; +const token = process.env.GITHUB_TOKEN; +assert.equal(process.env.GITHUB_ACTIONS, 'true', 'T04 verdict must originate in GitHub Actions'); +assert.equal(process.env.GITHUB_REPOSITORY, REPO); +assert.equal(process.env.GITHUB_EVENT_NAME, 'pull_request'); +assert.equal(process.env.GITHUB_JOB, 't04_acceptance'); +assert.match(runId || '', /^[1-9][0-9]*$/); +assert.ok(token, 'Authenticated GitHub API token required'); +const event = JSON.parse(fs.readFileSync(process.env.GITHUB_EVENT_PATH, 'utf8')); +assert.equal(event.pull_request?.number, 92, 'Only v0.13 integration PR #92 can be accepted'); +assert.equal(event.pull_request?.head?.ref, 'feature/v0.13-state-engine-v2'); +assert.equal(event.pull_request?.head?.repo?.full_name, REPO); +const receipts = JSON.parse(fs.readFileSync(path.join(ROOT, RECEIPTS), 'utf8')); +assert.equal(receipts.schemaVersion, '1.0.0'); +assert.match(receipts.reviewedCommit || '', SHA, 'Missing exact-source independent reviews'); +assert.equal(receipts.status, 'INDEPENDENT_REVIEWS_COMPLETE', 'Review register must be complete'); +assert.ok(Array.isArray(receipts.reviews) && receipts.reviews.length === 3, + 'Three separate authenticated code, architecture and security reviews are mandatory'); +const reviewedCommit = receipts.reviewedCommit; + +async function api(endpoint) { + const response = await fetch('https://api.github.com/repos/' + REPO + '/' + endpoint, { + headers: { Authorization: 'Bearer ' + token, Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2022-11-28', 'User-Agent': 'dkf-v013-t04-acceptance' }, + }); + assert.ok(response.ok, 'GitHub evidence unavailable: ' + endpoint + ' (' + response.status + ')'); + return response.json(); +} +async function graphql(query, variables) { + const response = await fetch('https://api.github.com/graphql', { + method: 'POST', + headers: { Authorization: 'Bearer ' + token, 'Content-Type': 'application/json', + 'User-Agent': 'dkf-v013-t04-acceptance' }, + body: JSON.stringify({ query, variables }), + }); + assert.ok(response.ok, 'Authenticated review-thread query failed'); + const data = await response.json(); + assert.ok(!data.errors?.length, 'Review-thread API returned GraphQL errors'); + return data.data; +} +// A receipt-only follow-up commit is permitted; every other tracked blob and +// executable mode must match the immutable source actually reviewed. +const tree = await api('git/trees/' + reviewedCommit + '?recursive=1'); +assert.ok(!tree.truncated && Array.isArray(tree.tree), 'Complete reviewed tree required'); +const expected = new Map(tree.tree.filter(x => x.type === 'blob') + .map(x => [x.path, { sha: x.sha, mode: x.mode }])); +const index = execFileSync('git', ['ls-files', '--stage', '-z'], { cwd: ROOT, encoding: 'utf8' }); +const observed = new Map(); +for (const line of index.split('\0').filter(Boolean)) { + const m = /^(\d+) ([a-f0-9]{40}) \d+\t([\s\S]+)$/.exec(line); + assert.ok(m, 'Unparseable tracked Git index entry'); + observed.set(m[3], { sha: m[2], mode: m[1] }); +} +for (const [name, blob] of expected) { + if (name !== RECEIPTS) assert.deepEqual(observed.get(name), blob, + 'Unreviewed source change or deletion: ' + name); +} +for (const name of observed.keys()) { + if (name !== RECEIPTS) assert.ok(expected.has(name), 'Unreviewed new source: ' + name); +} +assert.equal(execFileSync('git', ['status', '--porcelain', '--untracked-files=no'], { + cwd: ROOT, encoding: 'utf8' }).trim(), '', 'Modified tracked source invalidates review'); +const pr = await api('pulls/92'); +assert.equal(pr.state, 'open'); +assert.equal(pr.head.sha, event.pull_request.head.sha, 'Stale PR event/source'); +assert.equal(pr.base.ref, 'main'); +assert.equal(pr.head.repo.full_name, REPO); +assert.ok(Number.isSafeInteger(pr.user?.id) && pr.user.id > 0, + 'Immutable authenticated PR author ID required'); + +// Distinct GitHub-authored, post-source reviews. Self-authored PASS strings are +// never an approval. Each review must explicitly identify its role and SHA. +// Fetch all submitted PR reviews, including later revocations or source-stale +// reapprovals; the individual review endpoint alone cannot prove an approval +// is still the effective review from that independent identity. +const allSubmittedReviews = []; +for (let page = 1; ; page++) { + const batch = await api('pulls/92/reviews?per_page=100&page=' + page); + assert.ok(Array.isArray(batch), 'Authenticated PR review timeline is unavailable'); + allSubmittedReviews.push(...batch); + if (batch.length < 100) break; +} + +const reviewers = new Set(); +const authenticatedReviews = new Map(); +const reviewIds = new Set(); +for (const role of ROLES) { + const receipt = receipts.reviews.find(x => x?.role === role); + assert.ok(receipt, 'Missing independent role: ' + role); + assert.ok(Number.isSafeInteger(receipt.githubReviewId) && receipt.githubReviewId > 0); + assert.ok(!reviewIds.has(receipt.githubReviewId), 'Review reused for multiple roles'); + reviewIds.add(receipt.githubReviewId); + const review = await api('pulls/92/reviews/' + receipt.githubReviewId); + assert.equal(review.commit_id, reviewedCommit, 'Review is stale'); + assert.equal(review.state, 'APPROVED', 'Only authenticated GitHub approval is PASS'); + assert.ok(Number.isSafeInteger(review.user?.id) && review.user.id > 0 && + review.user.id !== pr.user.id, + 'Author cannot independently approve their own code, including after account rename'); + assert.ok(!reviewers.has(review.user.id), 'Distinct immutable reviewer ID required for each role'); + reviewers.add(review.user.id); + assertCurrentRoleApproval(review, allSubmittedReviews, reviewedCommit); + authenticatedReviews.set(role, review); + assert.ok((review.body || '').includes('DKF130-T04 ' + role + ' ' + reviewedCommit), + 'Approval must explicitly bind role and exact source SHA'); + const comments = await api('pulls/92/reviews/' + receipt.githubReviewId + '/comments?per_page=1'); + assert.ok(Array.isArray(comments) && comments.length === 0, + 'Review with findings is not a clean acceptance review'); +} +assert.equal(reviewIds.size, 3); +const query = `query($cursor:String){repository(owner:"eybersjp",name:"development-kit"){pullRequest(number:92){reviewThreads(first:100,after:$cursor){pageInfo{hasNextPage endCursor} nodes{isResolved}}}}}`; +let cursor = null; +for (;;) { + const data = await graphql(query, { cursor }); + const threads = data?.repository?.pullRequest?.reviewThreads; + assert.ok(Array.isArray(threads?.nodes), 'Review threads unavailable'); + assert.ok(threads.nodes.every(t => t.isResolved === true), 'Unresolved P1/P2 or other review threads'); + if (!threads.pageInfo?.hasNextPage) break; + assert.ok(threads.pageInfo.endCursor && threads.pageInfo.endCursor !== cursor); + cursor = threads.pageInfo.endCursor; +} +// A closed issue is necessary, not sufficient: retain independent physical +// power-loss evidence and externally verified artifact provenance. +const durabilityIssue = await api('issues/73'); +assert.equal(durabilityIssue.state, 'closed', 'Physical NTFS durability blocker #73 remains open'); +assert.notEqual(durabilityIssue.state_reason, 'not_planned', + 'Issue #73 cannot be waived instead of resolved'); +const acceptanceTrackingIssue = await api('issues/71'); +assert.notEqual(acceptanceTrackingIssue.state_reason, 'not_planned', + 'Risk-3 acceptance blocker #71 cannot be waived'); +// Issue #71 is the acceptance tracking ticket and may be closed AFTER the +// persisted ACCEPTED verdict; requiring closure here would deadlock the gate. +const physical = receipts.windowsPhysicalPowerLoss; +assert.ok(physical && physical.reviewedCommit === reviewedCommit + && typeof physical.artifactUrl === 'string' + && physical.artifactUrl.startsWith('https://github.com/' + REPO + '/actions/runs/') + && typeof physical.filesystem === 'string' && /^NTFS\b/.test(physical.filesystem) + && typeof physical.artifactName === 'string' && physical.artifactName.length > 0 + && /^sha256:[a-f0-9]{64}$/.test(physical.artifactDigest || '') + && Number.isSafeInteger(physical.independentReviewId) + && physical.cleanupSafety === 'CLEANUP_SAFE' + && physical.originalTreesPreserved === true + && physical.hashChainedJournalRecovered === true, + 'Independent, source-bound physical NTFS power-loss evidence is mandatory'); +const physicalReview = await api('pulls/92/reviews/' + physical.independentReviewId); +assert.equal(physicalReview.state, 'APPROVED'); +assert.ok(Number.isSafeInteger(physicalReview.user?.id) && physicalReview.user.id > 0 && + physicalReview.user.id !== pr.user.id, + 'Physical NTFS power-loss reviewer must be independent of the source author'); +assert.equal(physicalReview.commit_id, reviewedCommit); +// A previously approved physical test may later be revoked or superseded. +// Apply the same authenticated latest-decisive-review guard as the three roles. +assertCurrentRoleApproval(physicalReview, allSubmittedReviews, reviewedCommit); +assert.ok((physicalReview.body || '').includes('DKF130-T04 NTFS-PHYSICAL-POWER-LOSS ' + reviewedCommit), + 'Independent physical test review must explicitly bind source and evidence'); +const artifactRun = Number(physical.artifactUrl.match(/\/actions\/runs\/([0-9]+)/)?.[1]); +assert.ok(Number.isSafeInteger(artifactRun)); +const artifact = await api('actions/runs/' + artifactRun); +assert.equal(artifact.head_sha, reviewedCommit); +assert.equal(artifact.conclusion, 'success'); +const artifacts = await api('actions/runs/' + artifactRun + '/artifacts?per_page=100'); +const physicalArtifact = artifacts.artifacts?.find(x => x.name === physical.artifactName); +assert.ok(physicalArtifact && !physicalArtifact.expired && + physicalArtifact.digest === physical.artifactDigest, + 'Authenticated immutable physical NTFS power-loss artifact and SHA-256 digest required'); +assert.ok((physicalReview.body || '').includes(physical.artifactDigest), + 'Independent physical review must bind the exact artifact digest'); + +// Two distinct full Ubuntu/Windows workflows on the exact reviewed commit. +// The current run does not count while its acceptance job is executing. +assert.ok(Array.isArray(receipts.validationRunIds) && receipts.validationRunIds.length >= 2); +const validationRuns = new Set(receipts.validationRunIds); +assert.equal(validationRuns.size, receipts.validationRunIds.length); +for (const id of validationRuns) { + assert.ok(Number.isSafeInteger(id) && id > 0 && String(id) !== runId); + const run = await api('actions/runs/' + id); + assert.equal(run.head_sha, reviewedCommit, 'Validation run is not exact-source'); + assert.equal(run.event, 'pull_request'); + assert.equal(run.path, '.github/workflows/ci.yml', + 'Only the authoritative full CI workflow can count as risk-3 validation evidence'); + assert.equal(run.repository?.full_name, REPO, + 'Cross-repository or unrelated workflow evidence is not admissible'); + assert.equal(run.status, 'completed'); + // Diagnostic/production acceptance jobs may fail while validation succeeds: + // verify both full platform jobs individually, not overall run conclusion. + const jobs = []; + for (let page = 1; ; page++) { + const batch = await api('actions/runs/' + id + '/jobs?per_page=100&page=' + page); + jobs.push(...batch.jobs); + if (batch.jobs.length < 100) break; + } + for (const os of ['ubuntu-latest', 'windows-latest']) { + const matches = jobs.filter(job => job.name === 'validate (' + os + ')'); + assert.equal(matches.length, 1, 'Missing unique full validation for ' + os); + assert.equal(matches[0].conclusion, 'success', 'Full validation failed on ' + os); + // A separate workflow may reuse the validate job name without executing + // the v0.13 compatibility, source-security or acceptance negative controls. + // Require authenticated successful step-level witnesses on BOTH platforms. + for (const required of [ + 'Validate v0.13 State Engine V2 increment', + 'Validate v0.13 full compatibility regression', + 'Verify v0.13 risk-3 acceptance job contract (negative control)', + ]) { + const matchingSteps = (matches[0].steps || []).filter(step => step.name === required); + assert.equal(matchingSteps.length, 1, + 'Missing unique mandatory full-validation step on ' + os + ': ' + required); + for (const step of matchingSteps) { + assert.ok(step.conclusion === 'success', + 'Mandatory full-validation step did not pass on ' + os + ': ' + required); + } + } + } +} +// The canonical Development Contract acceptance engine, not a bespoke green +// flag, is the sole authority to produce a persisted ACCEPTED verdict. +const contract = loadDevelopmentContract('INC-DKF130-T04', ROOT); +assert.ok(contract, 'Persisted v0.13 Development Contract missing'); +assert.equal(contract.taskId, 'DKF130-T04'); +assert.equal(contract.risk.level, 3); +assert.equal(checkContractStaleness(contract, ROOT).stale, false); +const evidenceRunId = 'T04-V013-' + reviewedCommit.slice(0, 16); +const verifiedCiEvidence = [...validationRuns].map(id => ({ + type: 'test', deterministicVerification: true, verificationType: 'test', + url: 'https://github.com/' + REPO + '/actions/runs/' + id, + evidenceId: 'GITHUB-ACTIONS-' + id, reviewedCommit, +})); +const criteria = contract.acceptanceCriteria.map(c => ({ + id: c.id, status: 'PASS', + evidence: c.id === 'DKF-120-AC-015' + ? [...verifiedCiEvidence, { + type: 'external', authoritativeExternalState: true, + verificationType: 'security', + url: physical.artifactUrl, reviewedCommit, + evidenceId: 'INDEPENDENT-NTFS-POWER-LOSS-' + physical.independentReviewId, + }] + : verifiedCiEvidence, +})); +const verification = createVerificationRecord({ + contract, runId: evidenceRunId, role: 'test-engineer', + sourceFingerprint: contract.sourceFingerprint, + contextIsolation: 'rehydrated', criteria, +}); +const reviews = ROLES.map(role => createReviewResult({ + contract, runId: evidenceRunId, role, + sourceFingerprint: contract.sourceFingerprint, + contextIsolation: 'rehydrated', + createdAt: authenticatedReviews.get(role).submitted_at, + findings: [], +})); +const requiredControls = [ + { id: 'ledger-integrity', statement: 'Canonical event chain and index recovery remain integrity protected' }, + { id: 'multi-process-concurrency', statement: 'Concurrent writes and bootstrap recovery preserve committed events' }, + { id: 'acl-path-isolation', statement: 'Restore and canonical paths reject link and alias escapes' }, + { id: 'ntfs-crash-restore', statement: 'Supported NTFS sudden-power-loss restore and journal recovery verified' }, + { id: 'source-provenance', statement: 'Review and CI are bound to immutable final source' }, +]; +assert.ok(Array.isArray(receipts.securityControls) && + receipts.securityControls.length === requiredControls.length, + 'Authenticated independent security-control evidence is mandatory'); +const controlResults = []; +for (const control of requiredControls) { + const witness = receipts.securityControls.find(x => x?.id === control.id); + assert.ok(witness && witness.reviewedCommit === reviewedCommit && + Number.isSafeInteger(witness.githubReviewId) && + Number.isSafeInteger(witness.workflowRunId), + 'Security control missing independent source-bound witness: ' + control.id); + assert.ok(validationRuns.has(witness.workflowRunId), + 'Security control witness must cite an authenticated full Ubuntu/Windows validation run on the exact reviewed source'); + assert.equal(witness.githubReviewId, authenticatedReviews.get('security-reviewer').id, + 'Security control witnesses must bind the independently approved security review'); + const witnessReview = await api('pulls/92/reviews/' + witness.githubReviewId); + assert.equal(witnessReview.state, 'APPROVED'); + assert.equal(witnessReview.commit_id, reviewedCommit); + assert.ok((witnessReview.body || '').includes('DKF130-T04 CONTROL ' + control.id + ' ' + reviewedCommit)); + const witnessRun = await api('actions/runs/' + witness.workflowRunId); + assert.equal(witnessRun.head_sha, reviewedCommit); + assert.equal(witnessRun.status, 'completed'); + // The full Ubuntu/Windows validation jobs and mandatory steps were already + // authenticated above for this exact run ID. The separate independent + // acceptance job must fail closed before reviews/physical evidence exist, + // so requiring the *overall* run conclusion to be success would deadlock + // the first legitimate acceptance. Never weaken the per-platform checks. + controlResults.push({ + id: control.id, status: 'PASS', + evidence: [{ type: 'test', deterministicVerification: true, + reviewedCommit, evidenceId: 'CONTROL-' + control.id + '-' + witness.workflowRunId, + url: 'https://github.com/' + REPO + '/actions/runs/' + witness.workflowRunId }], + }); +} +const security = evaluateControlCoverage({ + contractId: contract.contractId, runId: evidenceRunId, + domain: 'security', expectedControls: requiredControls, results: controlResults, +}); +const architectureDrift = detectArchitectureDrift({ + baseline: { storageTechnologies: ['event-jsonl', 'derived-snapshot', 'disposable-json-index'], + migrationStrategies: ['legacy-migration'] }, + current: { storageTechnologies: ['event-jsonl', 'derived-snapshot', 'disposable-json-index'], + migrationStrategies: ['legacy-migration', 'explicit-offline-backup-recovery'] }, + expectedChanges: ['migration-strategy:explicit-offline-backup-recovery'], +}); +const decision = decideAcceptance({ + contract, verification, reviews, controlManifests: [security], + architectureDrift, rootDir: ROOT, +}); +validateAcceptanceRecord(decision); +assert.equal(decision.state, 'ACCEPTED', + 'Deterministic risk-3 gate requires complete canonical verification, control and review records; never infer ACCEPTED from CI'); +const runDirectory = getRunDirectory(ROOT, contract.contractId, evidenceRunId); +fs.mkdirSync(runDirectory, { recursive: true }); +persistVerificationRecord(verification, ROOT); +persistControlManifest(security, ROOT); +const destination = path.join(ROOT, '.development-kit', 'acceptance', 'DKF130-T04.json'); +fs.mkdirSync(path.dirname(destination), { recursive: true }); +fs.writeFileSync(destination, JSON.stringify({ + ...decision, reviewedCommit, validationRunIds: [...validationRuns], + independentReviewIds: [...reviewIds], physicalPowerLoss: physical, + verification, reviews, securityControl: security, architectureDrift, +}, null, 2) + '\n', { flag: 'wx' }); +console.log('DKF130-T04 ACCEPTED ' + reviewedCommit); diff --git a/scripts/v013-t04-review-provenance.mjs b/scripts/v013-t04-review-provenance.mjs new file mode 100644 index 000000000..c77b8f2e7 --- /dev/null +++ b/scripts/v013-t04-review-provenance.mjs @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict'; + +// GitHub's single-review endpoint can return an authentic APPROVED receipt +// even after that reviewer subsequently requested changes or reapproved a +// different source commit. Require the latest *decisive* submitted review +// from the same authenticated identity to be this exact source-bound receipt. +export function assertCurrentRoleApproval(approval, submittedReviews, reviewedCommit) { + assert.ok(approval && Number.isSafeInteger(approval.id) && approval.id > 0, + 'Authenticated role review ID is required'); + assert.equal(approval.state, 'APPROVED', 'Role review must be approved'); + assert.equal(approval.commit_id, reviewedCommit, 'Role approval source is stale'); + // GitHub logins are mutable and case-insensitive; the numeric account ID is + // stable across renames and must bind every later decisive submission. + const accountId = approval.user?.id; + assert.ok(Number.isSafeInteger(accountId) && accountId > 0, + 'Immutable authenticated GitHub reviewer ID is required'); + assert.ok(Array.isArray(submittedReviews), 'Authenticated PR review timeline is unavailable'); + const decisive = submittedReviews.filter(review => + review?.user?.id === accountId && + ['APPROVED', 'CHANGES_REQUESTED', 'DISMISSED'].includes(review.state)); + assert.ok(decisive.length > 0, 'Role approval absent from authenticated review timeline'); + for (const review of decisive) { + assert.ok(Number.isSafeInteger(review.id) && review.id > 0 && + typeof review.submitted_at === 'string' && + Number.isFinite(Date.parse(review.submitted_at)), + 'Submitted role review lacks an authenticated ordering witness'); + } + decisive.sort((a, b) => + Date.parse(a.submitted_at) - Date.parse(b.submitted_at) || a.id - b.id); + const effective = decisive.at(-1); + assert.equal(effective.id, approval.id, + 'Role approval was superseded by a later decisive review'); + assert.equal(effective.state, 'APPROVED', + 'Effective role review is not approved'); + assert.equal(effective.commit_id, reviewedCommit, + 'Effective role approval source is stale'); +} diff --git a/scripts/v071-regression.test.mjs b/scripts/v071-regression.test.mjs index 1c6e21024..7640e947b 100644 --- a/scripts/v071-regression.test.mjs +++ b/scripts/v071-regression.test.mjs @@ -85,7 +85,16 @@ test('TEST B: First lifecycle interaction -> Require persistent lifecycle state const saved = getCurrentState(rootDir); assert.ok(saved, 'State must be persisted to disk'); assert.equal(saved.currentStage, 'UNDERSTAND'); - assert.ok(existsSync(join(rootDir, '.development-kit', 'autopilot', 'state', 'current.json'))); + // v0.13 fresh projects persist in the canonical append-only ledger, not + // the pre-migration legacy current.json pointer. The round-trip assertion + // above must remain true without inventing a legacy authority file. + const canonicalRoot = join(rootDir, '.development-kit', 'state'); + assert.ok(existsSync(join(canonicalRoot, 'events.jsonl')), + 'Fresh-project lifecycle history must be durably represented in the canonical ledger'); + assert.ok(existsSync(join(canonicalRoot, 'snapshot.json')), + 'The canonical lifecycle snapshot must be materialized'); + assert.equal(existsSync(join(rootDir, '.development-kit', 'autopilot', 'state', 'current.json')), false, + 'Fresh V2 projects must not silently create a legacy current.json authority'); }); test('TEST C: Store a decision naturally -> Destroy in-memory service -> Reinitialize -> Require recall succeeds', async (t) => { @@ -371,3 +380,29 @@ test('TEST M: remembered approval still cannot authorize /dk-ship or any consequ assert.equal(shipMeta.requiresApproval, true); assert.equal(shipMeta.safetyLevel, 'consequential'); }); + + +test('V13-CI-ROUTING: v0.13 validation is selected by source presence, not mutable PR branch name', (t) => { + // The published v0.12.1 line does not contain this contract. Its CI must + // continue running the v0.12 release scope without enabling deferred T04. + const engine = join(REPO_ROOT, 'runtime', 'orchestration', 'state-engine-v2.mjs'); + if (!existsSync(engine)) { + t.skip('v0.13 T04 runtime is absent from the released baseline'); + return; + } + assert.ok(existsSync(join(REPO_ROOT, 'scripts', 'v013-state-engine-v2-contract.mjs')), + 'Present v0.13 runtime must retain its required risk-3 Development Contract'); + const workflow = readFileSync(join(REPO_ROOT, '.github', 'workflows', 'ci.yml'), 'utf8'); + const sourceProbe = String.raw`hashFiles\('runtime/orchestration/state-engine-v2\.mjs'\)`; + const positive = new RegExp(String.raw`if:\s*\$\{\{\s*${sourceProbe}\s*!=\s*''\s*\}\}`); + const negative = new RegExp(String.raw`if:\s*\$\{\{\s*${sourceProbe}\s*==\s*''\s*\}\}`); + // Source-dependent validation must never be branch-gated. The distinct + // risk-3 acceptance job is deliberately restricted to the integration PR. + const validateJob = workflow.split(/^ validate:\s*$/m)[1]?.split(/^ [a-z][\w-]*:\s*$/m)[0] ?? ''; + assert.ok(validateJob, 'Source-dependent validate job must exist'); + assert.doesNotMatch(validateJob, /github\.head_ref/, + 'Branch-name conditions can silently skip T04 validation after a merge or branch rename'); + assert.match(workflow, positive, 'v0.13 test lane must run whenever its canonical runtime is in the checkout'); + assert.match(workflow, negative, 'v0.12-only release lane must run only without the v0.13 runtime'); + assert.match(workflow, /run: npm run v013:regression/, 'Source-bound v0.13 lane must include full compatibility regression'); +}); diff --git a/scripts/windows-durable-rename.ps1 b/scripts/windows-durable-rename.ps1 new file mode 100644 index 000000000..1f3d5641f --- /dev/null +++ b/scripts/windows-durable-rename.ps1 @@ -0,0 +1,432 @@ +# DKF v0.13 Windows State Engine durability helper. +# +# Narrow audited boundary for: +# - local storage-profile detection, +# - same-volume write-through directory rename, +# - write-capable parent-directory FlushFileBuffers barrier. +# +# This helper does NOT claim universal physical power-loss durability. Runtime +# recovery remains witness-based and fail-closed. Arguments are literal process +# arguments; this script never evaluates command text or interpolates a shell. +param( + [Parameter(Mandatory = $true)] + [ValidateSet('Probe', 'Rename', 'FlushDirectory', 'CreateSecureStage', 'CopyAclTree')] + [string]$Operation, + + [string]$Path, + [string]$From, + [string]$To +) + +$ErrorActionPreference = 'Stop' + +$source = @' +using System; +using System.ComponentModel; +using System.Runtime.InteropServices; +using System.Text; +using Microsoft.Win32.SafeHandles; + +public static class DkfWindowsDurability { + public const uint MOVEFILE_WRITE_THROUGH = 0x00000008; + public const uint GENERIC_WRITE = 0x40000000; + public const uint FILE_SHARE_READ = 0x00000001; + public const uint FILE_SHARE_WRITE = 0x00000002; + public const uint FILE_SHARE_DELETE = 0x00000004; + public const uint OPEN_EXISTING = 3; + public const uint FILE_FLAG_WRITE_THROUGH = 0x80000000; + public const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000; + public const uint DRIVE_FIXED = 3; + + [DllImport("kernel32.dll", EntryPoint = "MoveFileExW", + CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool MoveFileEx( + string existingName, + string newName, + uint flags); + + [DllImport("kernel32.dll", EntryPoint = "GetVolumePathNameW", + CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetVolumePathName( + string fileName, + StringBuilder volumePathName, + uint bufferLength); + + [DllImport("kernel32.dll", EntryPoint = "GetVolumeInformationW", + CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetVolumeInformation( + string rootPathName, + StringBuilder volumeNameBuffer, + uint volumeNameSize, + out uint volumeSerialNumber, + out uint maximumComponentLength, + out uint fileSystemFlags, + StringBuilder fileSystemNameBuffer, + uint fileSystemNameSize); + + [DllImport("kernel32.dll", EntryPoint = "GetDriveTypeW", + CharSet = CharSet.Unicode)] + private static extern uint GetDriveType(string rootPathName); + + [DllImport("kernel32.dll", EntryPoint = "CreateFileW", + CharSet = CharSet.Unicode, SetLastError = true)] + private static extern SafeFileHandle CreateFile( + string fileName, + uint desiredAccess, + uint shareMode, + IntPtr securityAttributes, + uint creationDisposition, + uint flagsAndAttributes, + IntPtr templateFile); + + [DllImport("kernel32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool FlushFileBuffers(SafeFileHandle handle); + + // SetNamedSecurityInfoW applies NTFS auto-inheritance; .NET SetAccessControl + // may clear SE_DACL_AUTO_INHERITED even when all current ACEs match. + private const uint SE_FILE_OBJECT = 1; + private const uint DACL_SECURITY_INFORMATION = 0x00000004; + private const uint PROTECTED_DACL_SECURITY_INFORMATION = 0x80000000; + private const uint UNPROTECTED_DACL_SECURITY_INFORMATION = 0x20000000; + + [DllImport("advapi32.dll", EntryPoint = "GetNamedSecurityInfoW", + CharSet = CharSet.Unicode)] + private static extern uint GetNamedSecurityInfo( + string name, uint objectType, uint securityInfo, + out IntPtr owner, out IntPtr group, out IntPtr dacl, + out IntPtr sacl, out IntPtr securityDescriptor); + + [DllImport("advapi32.dll", EntryPoint = "SetNamedSecurityInfoW", + CharSet = CharSet.Unicode)] + private static extern uint SetNamedSecurityInfo( + string name, uint objectType, uint securityInfo, + IntPtr owner, IntPtr group, IntPtr dacl, IntPtr sacl); + + // Legacy DACLs without SE_DACL_AUTO_INHERITED must not be silently + // converted into auto-inheriting descriptors by SetNamedSecurityInfoW. + // SetFileSecurityW is used only for legacy DACLs without the + // auto-inherited control bit; exact protected status is verified after. + [DllImport("advapi32.dll", EntryPoint = "SetFileSecurityW", + CharSet = CharSet.Unicode, SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetFileSecurity( + string name, uint securityInfo, IntPtr securityDescriptor); + + [DllImport("kernel32.dll", EntryPoint = "LocalFree")] + private static extern IntPtr LocalFree(IntPtr handle); + + public static void CopyDaclWithInheritance(string from, string to, bool protectedDacl, bool autoInherited) { + IntPtr owner, group, dacl, sacl, descriptor; + uint status = GetNamedSecurityInfo(from, SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, out owner, out group, out dacl, + out sacl, out descriptor); + if (status != 0) { + throw new Win32Exception((int)status, "GetNamedSecurityInfoW source DACL failed"); + } + try { + // Never install a NULL DACL: it grants unrestricted access. + if (dacl == IntPtr.Zero) { + throw new InvalidOperationException("Source has a NULL DACL; refusing staged ACL publication"); + } + if (autoInherited) { + uint flags = DACL_SECURITY_INFORMATION | + (protectedDacl ? PROTECTED_DACL_SECURITY_INFORMATION : + UNPROTECTED_DACL_SECURITY_INFORMATION); + status = SetNamedSecurityInfo(to, SE_FILE_OBJECT, flags, + IntPtr.Zero, IntPtr.Zero, dacl, IntPtr.Zero); + if (status != 0) { + throw new Win32Exception((int)status, "SetNamedSecurityInfoW staged DACL failed"); + } + } else { + if (!SetFileSecurity(to, DACL_SECURITY_INFORMATION, descriptor)) { + throw LastError("SetFileSecurityW legacy DACL copy failed"); + } + } + } finally { + if (descriptor != IntPtr.Zero) LocalFree(descriptor); + } + } + + private static Win32Exception LastError(string operation) { + return new Win32Exception(Marshal.GetLastWin32Error(), operation); + } + + public static string VolumeRoot(string path) { + var buffer = new StringBuilder(32768); + if (!GetVolumePathName(path, buffer, (uint)buffer.Capacity)) { + throw LastError("GetVolumePathNameW failed"); + } + return buffer.ToString(); + } + + public static string FileSystem(string root) { + var volume = new StringBuilder(261); + var fs = new StringBuilder(261); + uint serial; + uint maxComponent; + uint flags; + if (!GetVolumeInformation( + root, volume, (uint)volume.Capacity, + out serial, out maxComponent, out flags, + fs, (uint)fs.Capacity)) { + throw LastError("GetVolumeInformationW failed"); + } + return fs.ToString(); + } + + public static uint DriveType(string root) { + return GetDriveType(root); + } + + public static void FlushDirectory(string directory) { + const uint share = FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE; + const uint flags = FILE_FLAG_BACKUP_SEMANTICS | FILE_FLAG_WRITE_THROUGH; + using (var handle = CreateFile( + directory, + GENERIC_WRITE, + share, + IntPtr.Zero, + OPEN_EXISTING, + flags, + IntPtr.Zero)) { + if (handle.IsInvalid) { + throw LastError("CreateFileW directory durability handle failed"); + } + if (!FlushFileBuffers(handle)) { + throw LastError("FlushFileBuffers directory barrier failed"); + } + } + } + + public static void RenameWriteThrough(string from, string to) { + if (!MoveFileEx(from, to, MOVEFILE_WRITE_THROUGH)) { + throw LastError("MoveFileExW(MOVEFILE_WRITE_THROUGH) failed"); + } + } +} +'@ + +Add-Type -TypeDefinition $source -ErrorAction Stop + +function Get-DkfStorageProfile([string]$InputPath) { + if ([string]::IsNullOrWhiteSpace($InputPath)) { + throw 'Path is required' + } + $full = [System.IO.Path]::GetFullPath($InputPath) + $root = [DkfWindowsDurability]::VolumeRoot($full) + $fileSystem = [DkfWindowsDurability]::FileSystem($root) + $driveType = [DkfWindowsDurability]::DriveType($root) + [PSCustomObject]@{ + path = $full + volumeRoot = $root + fileSystem = $fileSystem + driveType = [int]$driveType + localFixed = ($driveType -eq [DkfWindowsDurability]::DRIVE_FIXED) + supportedAutomaticRestore = ( + $driveType -eq [DkfWindowsDurability]::DRIVE_FIXED -and + $fileSystem -eq 'NTFS' + ) + } +} + + +# Windows may clear the SE_DACL_AUTO_INHERITED control bit on a protected +# DACL when applying an otherwise identical descriptor to a new object. +# Compare protection and ACE SDDL exactly. The D:AI auto-inheritance +# control flag can affect future child inheritance, so it must not be +# normalized away merely because existing ACEs match. +function Get-DkfComparableDacl([System.Security.AccessControl.ObjectSecurity]$Acl) { + $section = [System.Security.AccessControl.AccessControlSections]::Access + $sddl = $Acl.GetSecurityDescriptorSddlForm($section) + return $sddl +} + +# Create a protected staging root with its DACL supplied atomically at mkdir +# time. A sibling's inherited ACL must never expose copied project data. +function New-DkfSecureStage([string]$StagePath, [string]$SourcePath) { + if ([string]::IsNullOrWhiteSpace($StagePath)) { throw 'Stage path is required' } + $stage = [System.IO.Path]::GetFullPath($StagePath) + $parent = [System.IO.Path]::GetDirectoryName($stage) + if (-not [System.IO.Directory]::Exists($parent)) { throw 'Stage parent is missing' } + $profile = Get-DkfStorageProfile $parent + if (-not $profile.supportedAutomaticRestore) { throw 'Secure staging requires local fixed NTFS' } + if ([System.IO.Directory]::Exists($stage) -or [System.IO.File]::Exists($stage)) { + throw 'Secure stage already exists' + } + + # Supply a private maintenance-only DACL *at creation*, never inherit the + # sibling parent's potentially wider DACL while private data is staged. + $privateAcl = New-Object System.Security.AccessControl.DirectorySecurity + $privateAcl.SetAccessRuleProtection($true, $false) + $sid = [System.Security.Principal.WindowsIdentity]::GetCurrent().User + $rule = [System.Security.AccessControl.FileSystemAccessRule]::new( + $sid, + [System.Security.AccessControl.FileSystemRights]::FullControl, + ([System.Security.AccessControl.InheritanceFlags]::ContainerInherit -bor + [System.Security.AccessControl.InheritanceFlags]::ObjectInherit), + [System.Security.AccessControl.PropagationFlags]::None, + [System.Security.AccessControl.AccessControlType]::Allow) + $privateAcl.AddAccessRule($rule) + [System.IO.Directory]::CreateDirectory($stage, $privateAcl) | Out-Null + + if (-not [string]::IsNullOrWhiteSpace($SourcePath) -and + -not [System.IO.Directory]::Exists($SourcePath)) { + throw 'Original source root is missing before staging' + } + # The original root may grant broad access while individual children + # contain confidential ACLs. Never widen the stage root before the + # entire copied tree has received and verified its original child DACLs. + $actual = [System.IO.Directory]::GetAccessControl($stage) + if (-not $actual.AreAccessRulesProtected) { + throw 'Private stage DACL unexpectedly inherits parent access' + } + $rules = $actual.GetAccessRules($true, $true, + [System.Security.Principal.SecurityIdentifier]) + if ($rules.Count -lt 1) { throw 'Private stage has no maintenance ACL' } + foreach ($entry in $rules) { + if ($entry.IsInherited -or $entry.IdentityReference.Value -ne $sid.Value -or + $entry.AccessControlType -ne [System.Security.AccessControl.AccessControlType]::Allow) { + throw 'Private stage has unexpected additional or inherited access' + } + } + return $stage +} + +# Post-order: descendants are copied while the staging root remains private. +# Copy exact source DACLs only after all copied bytes are present. Any failure +# prevents promotion and leaves the original untouched. +function Copy-DkfAclTree([string]$Source, [string]$Destination, [bool]$VerifyOnly = $false) { + $src = Get-Item -LiteralPath $Source -Force -ErrorAction Stop + $dst = Get-Item -LiteralPath $Destination -Force -ErrorAction Stop + $srcReparse = ($src.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + $dstReparse = ($dst.Attributes -band [System.IO.FileAttributes]::ReparsePoint) -ne 0 + if ($srcReparse -or $dstReparse) { + # Do not follow symlinks or junctions while traversing source ACLs. + # Existing relative symbolic links are copied verbatim by fs.cpSync; + # their containing directory remains protected by its source DACL. + if ($srcReparse -and $dstReparse -and + $src.LinkType -eq 'SymbolicLink' -and $dst.LinkType -eq 'SymbolicLink') { + return + } + throw 'Unsupported or mismatched reparse point during NTFS ACL preservation' + } + if ($src.PSIsContainer -ne $dst.PSIsContainer) { + throw 'Source and staged object kinds differ during ACL preservation' + } + if ($src.PSIsContainer) { + foreach ($child in Get-ChildItem -LiteralPath $Source -Force -ErrorAction Stop) { + Copy-DkfAclTree (Join-Path $Source $child.Name) (Join-Path $Destination $child.Name) $VerifyOnly + } + } + if ($src.PSIsContainer) { + $acl = [System.IO.Directory]::GetAccessControl($Source) + if (-not $VerifyOnly) { [DkfWindowsDurability]::CopyDaclWithInheritance($Source, $Destination, $acl.AreAccessRulesProtected, ($acl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access) -match '^D:(?:P)?AI')) } + $actualAcl = [System.IO.Directory]::GetAccessControl($Destination) + } else { + $acl = [System.IO.File]::GetAccessControl($Source) + if (-not $VerifyOnly) { [DkfWindowsDurability]::CopyDaclWithInheritance($Source, $Destination, $acl.AreAccessRulesProtected, ($acl.GetSecurityDescriptorSddlForm([System.Security.AccessControl.AccessControlSections]::Access) -match '^D:(?:P)?AI')) } + $actualAcl = [System.IO.File]::GetAccessControl($Destination) + } + $sections = [System.Security.AccessControl.AccessControlSections]::Access + $expected = $acl.GetSecurityDescriptorSddlForm($sections) + $actual = $actualAcl.GetSecurityDescriptorSddlForm($sections) + if ($expected -cne $actual) { + throw ('Staged NTFS DACL does not match source after copy: source={0} staged={1}' -f $expected, $actual) + } +} + +switch ($Operation) { + + 'CreateSecureStage' { + $created = New-DkfSecureStage $Path $From + [PSCustomObject]@{ operation = 'CreateSecureStage'; path = $created; protected = $true } | + ConvertTo-Json -Compress + break + } + + 'CopyAclTree' { + if ([string]::IsNullOrWhiteSpace($From) -or [string]::IsNullOrWhiteSpace($To)) { + throw 'Source and stage paths are required' + } + $sourcePath = [System.IO.Path]::GetFullPath($From) + $destinationPath = [System.IO.Path]::GetFullPath($To) + $sourceProfile = Get-DkfStorageProfile $sourcePath + $destinationProfile = Get-DkfStorageProfile $destinationPath + if (-not $sourceProfile.supportedAutomaticRestore -or + -not $destinationProfile.supportedAutomaticRestore -or + $sourceProfile.volumeRoot -ne $destinationProfile.volumeRoot) { + throw 'NTFS ACL copy requires local fixed same-volume source and stage' + } + Copy-DkfAclTree $sourcePath $destinationPath + # A parent DACL change can propagate to already-copied descendants. + # Verify the entire tree again after all parent operations complete. + Copy-DkfAclTree $sourcePath $destinationPath $true + [PSCustomObject]@{ operation = 'CopyAclTree'; source = $sourcePath; + destination = $destinationPath; verified = $true } | ConvertTo-Json -Compress + break + } + + + 'Probe' { + $profile = Get-DkfStorageProfile $Path + $profile | ConvertTo-Json -Compress + break + } + + 'FlushDirectory' { + $profile = Get-DkfStorageProfile $Path + if (-not $profile.supportedAutomaticRestore) { + throw ("Unsupported automatic Windows durability profile: filesystem={0}, driveType={1}" -f + $profile.fileSystem, $profile.driveType) + } + [DkfWindowsDurability]::FlushDirectory($profile.path) + [PSCustomObject]@{ + operation = 'FlushDirectory' + profile = $profile + flushed = $true + } | ConvertTo-Json -Compress -Depth 4 + break + } + + 'Rename' { + if ([string]::IsNullOrWhiteSpace($From) -or [string]::IsNullOrWhiteSpace($To)) { + throw 'From and To are required for Rename' + } + $sourcePath = [System.IO.Path]::GetFullPath($From) + $destinationPath = [System.IO.Path]::GetFullPath($To) + $destinationParent = [System.IO.Path]::GetDirectoryName($destinationPath) + $sourceProfile = Get-DkfStorageProfile $sourcePath + $destinationProfile = Get-DkfStorageProfile $destinationParent + + if (-not $sourceProfile.supportedAutomaticRestore -or + -not $destinationProfile.supportedAutomaticRestore) { + throw ("Unsupported automatic Windows durability profile: source={0}/{1}, destination={2}/{3}" -f + $sourceProfile.fileSystem, $sourceProfile.driveType, + $destinationProfile.fileSystem, $destinationProfile.driveType) + } + if ($sourceProfile.volumeRoot -ne $destinationProfile.volumeRoot) { + throw 'Durable directory rename requires source and destination on the same volume' + } + if ([System.IO.Directory]::Exists($destinationPath) -or + [System.IO.File]::Exists($destinationPath)) { + throw 'Durable rename destination already exists' + } + + [DkfWindowsDurability]::RenameWriteThrough($sourcePath, $destinationPath) + [DkfWindowsDurability]::FlushDirectory($destinationParent) + + [PSCustomObject]@{ + operation = 'Rename' + source = $sourcePath + destination = $destinationPath + profile = $destinationProfile + renamed = $true + parentFlushCompleted = $true + } | ConvertTo-Json -Compress -Depth 4 + break + } +}